Skip to main content
This tutorial walks you through creating a simple counter application using Rezi’s ui.* factory functions. You’ll learn the basics of state management, widget composition, and event handling.

Prerequisites

Before starting, make sure you have:
  • Node.js 18+ or Bun 1.3+ installed
  • A terminal with 256-color or true-color support
If you haven’t installed Rezi yet, check out the Installation Guide first.

Step 1: Scaffold a New Project

The fastest way to start is using create-rezi:
npm create rezi counter-app
cd counter-app
When prompted:
  1. Select minimal template (or press Enter for the default)
  2. Wait for dependencies to install

Step 2: Understanding the Project Structure

The minimal template creates this structure:
counter-app/
├── src/
│   ├── main.ts              # App entry point
│   ├── types.ts             # State and action types
│   ├── theme.ts             # Theme configuration
│   ├── screens/
│   │   └── main-screen.ts   # View function
│   ├── helpers/
│   │   ├── state.ts         # State reducer
│   │   └── keybindings.ts   # Key commands
│   └── __tests__/
│       ├── render.test.ts
│       ├── reducer.test.ts
│       └── keybindings.test.ts
├── package.json
└── tsconfig.json

Step 3: Build a Counter App

Let’s modify the generated code to create a counter. Open src/types.ts and define your state:
src/types.ts
export type AppState = {
  count: number;
  themeName: "dark" | "light" | "nord" | "dracula" | "dimmed" | "high-contrast";
};

export type AppAction =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "reset" }
  | { type: "cycle-theme" };

Step 4: Create the Reducer

Open src/helpers/state.ts and implement the state logic:
src/helpers/state.ts
import type { AppState, AppAction } from "../types.js";

export function createInitialState(): AppState {
  return {
    count: 0,
    themeName: "dark",
  };
}

const THEMES = ["dark", "light", "nord", "dracula", "dimmed", "high-contrast"] as const;

export function reduceAppState(state: AppState, action: AppAction): AppState {
  switch (action.type) {
    case "increment":
      return { ...state, count: state.count + 1 };
    
    case "decrement":
      return { ...state, count: state.count - 1 };
    
    case "reset":
      return { ...state, count: 0 };
    
    case "cycle-theme": {
      const currentIndex = THEMES.indexOf(state.themeName);
      const nextIndex = (currentIndex + 1) % THEMES.length;
      return { ...state, themeName: THEMES[nextIndex] };
    }
    
    default:
      return state;
  }
}

Step 5: Create the View

Open src/screens/main-screen.ts and build your UI:
src/screens/main-screen.ts
import { ui } from "@rezi-ui/core";
import type { AppState } from "../types.js";

type Actions = {
  onIncrement: () => void;
  onDecrement: () => void;
  onReset: () => void;
  onCycleTheme: () => void;
};

export function renderMainScreen(state: AppState, actions: Actions) {
  return ui.page({
    p: 1,
    gap: 1,
    header: ui.header({
      title: "Counter App",
      subtitle: "Built with Rezi",
    }),
    body: ui.column({ gap: 2 }, [
      // Counter display
      ui.panel("Count", [
        ui.row({ gap: 1, items: "center" }, [
          ui.text(String(state.count), { variant: "heading" }),
          ui.spacer({ flex: 1 }),
          ui.badge(state.count > 0 ? "positive" : "neutral", String(state.count)),
        ]),
      ]),
      
      // Controls
      ui.panel("Controls", [
        ui.actions([
          ui.button({
            id: "dec",
            label: "Decrement (-)",
            intent: "secondary",
            onPress: actions.onDecrement,
          }),
          ui.button({
            id: "reset",
            label: "Reset (R)",
            onPress: actions.onReset,
          }),
          ui.button({
            id: "inc",
            label: "Increment (+)",
            intent: "primary",
            onPress: actions.onIncrement,
          }),
        ]),
      ]),
      
      // Info
      ui.callout("info", [
        ui.text("Press + to increment, - to decrement, R to reset."),
        ui.text("Press T to cycle themes. Press Q to quit."),
      ]),
    ]),
  });
}

Step 6: Wire Up the App

Open src/main.ts and connect everything:
src/main.ts
import { createNodeApp } from "@rezi-ui/node";
import { exit } from "node:process";
import { createInitialState, reduceAppState } from "./helpers/state.js";
import { renderMainScreen } from "./screens/main-screen.js";
import { themeSpec } from "./theme.js";
import type { AppState, AppAction } from "./types.js";

const initialState = createInitialState();

const app = createNodeApp({
  initialState,
  config: { fpsCap: 30 },
  theme: themeSpec(initialState.themeName).theme,
});

function dispatch(action: AppAction): void {
  let themeChanged = false;
  let nextThemeName = initialState.themeName;

  app.update((previous) => {
    const next = reduceAppState(previous, action);
    if (next.themeName !== previous.themeName) {
      nextThemeName = next.themeName;
      themeChanged = true;
    }
    return next;
  });

  if (themeChanged) {
    app.setTheme(themeSpec(nextThemeName).theme);
  }
}

app.view((state: AppState) =>
  renderMainScreen(state, {
    onIncrement: () => dispatch({ type: "increment" }),
    onDecrement: () => dispatch({ type: "decrement" }),
    onReset: () => dispatch({ type: "reset" }),
    onCycleTheme: () => dispatch({ type: "cycle-theme" }),
  }),
);

app.keys({
  q: () => app.stop(),
  "+": () => dispatch({ type: "increment" }),
  "shift+=": () => dispatch({ type: "increment" }),
  "-": () => dispatch({ type: "decrement" }),
  r: () => dispatch({ type: "reset" }),
  t: () => dispatch({ type: "cycle-theme" }),
});

process.once("SIGINT", () => app.stop());
process.once("SIGTERM", () => app.stop());

await app.start();
exit(0);

Step 7: Run Your App

npm start
You should see a counter interface with:
  • A count display with a badge
  • Three buttons for increment, decrement, and reset
  • Help text with keyboard shortcuts
  • Theme cycling support
Keyboard shortcuts:
  • + — Increment
  • - — Decrement
  • R — Reset
  • T — Cycle themes
  • Q — Quit

Understanding the Code

State Management

Rezi uses a simple reducer pattern:
  1. State typeAppState defines what data your app holds
  2. ActionsAppAction union defines all possible state changes
  3. ReducerreduceAppState() applies actions to produce new state
  4. Updateapp.update() triggers a state transition and re-render

Widget Composition

Widgets are composed using factory functions from ui.*:
  • ui.page() — Root container with padding and layout
  • ui.header() — Title bar with subtitle
  • ui.panel() — Labeled content section
  • ui.button() — Interactive button with intent styling
  • ui.callout() — Info/warning/error message box
All widgets accept style props (p, gap, flex, etc.) and semantic props (variant, intent, etc.).

Event Handling

Events are handled via callbacks:
  • Widget callbacksonPress, onInput, onChange, etc.
  • Global keybindingsapp.keys() for keyboard shortcuts
  • Lifecycle hooksapp.start(), app.stop(), app.dispose()

Using JSX (Optional)

You can also build the same counter with JSX:
src/screens/main-screen.tsx
import { Page, Header, Column, Panel, Row, Text, Spacer, Badge, Button, Actions, Callout } from "@rezi-ui/jsx";
import type { AppState } from "../types.js";

type ActionsType = {
  onIncrement: () => void;
  onDecrement: () => void;
  onReset: () => void;
  onCycleTheme: () => void;
};

export function renderMainScreen(state: AppState, actions: ActionsType) {
  return (
    <Page
      p={1}
      gap={1}
      header={<Header title="Counter App" subtitle="Built with Rezi" />}
      body={
        <Column gap={2}>
          <Panel title="Count">
            <Row gap={1} items="center">
              <Text variant="heading">{String(state.count)}</Text>
              <Spacer flex={1} />
              <Badge variant={state.count > 0 ? "positive" : "neutral"}>
                {String(state.count)}
              </Badge>
            </Row>
          </Panel>

          <Panel title="Controls">
            <Actions>
              <Button id="dec" label="Decrement (-)" intent="secondary" onPress={actions.onDecrement} />
              <Button id="reset" label="Reset (R)" onPress={actions.onReset} />
              <Button id="inc" label="Increment (+)" intent="primary" onPress={actions.onIncrement} />
            </Actions>
          </Panel>

          <Callout variant="info">
            <Text>Press + to increment, - to decrement, R to reset.</Text>
            <Text>Press T to cycle themes. Press Q to quit.</Text>
          </Callout>
        </Column>
      }
    />
  );
}
See JSX Documentation for setup instructions.

Next Steps

Explore Widgets

Browse the complete widget catalog

Learn Concepts

Understand Rezi’s architecture and patterns

Styling Guide

Master layout, theming, and design tokens

Example Templates

Explore advanced templates and patterns

Build docs developers (and LLMs) love