Recommended Patterns
This guide documents the best practices for building Rezi applications. These patterns are demonstrated in the create-rezi templates and have proven effective in production TUI apps.
This guide documents the best practices for building Rezi applications. These patterns are demonstrated in the create-rezi templates and have proven effective in production TUI apps.
App Structure
Follow the template project layout to keep your app organized, testable, and maintainable:
my-tui-app/
src/
types.ts # State type, action union, domain types
theme.ts # Theme configuration and switching
helpers/
keybindings.ts # Centralized key mappings
actions.ts # Reducer and dispatch logic
screens/
main.ts # Main screen view function
settings.ts # Settings screen view function
widgets/
statusBar.ts # Reusable custom widgets
index.ts # App entry point (createNodeApp + wiring)
tsconfig.json
package.jsonThe entry point (index.ts) should be thin -- it creates the app, wires up keybindings, sets the view, and calls app.run(). All logic lives in the other modules.
// src/index.ts
import { createNodeApp } from "@rezi-ui/node";
import type { State } from "./types.js";
import { initialState } from "./helpers/actions.js";
import { registerKeybindings } from "./helpers/keybindings.js";
import { mainScreen } from "./screens/main.js";
const app = createNodeApp<State>({ initialState });
app.view(mainScreen(app));
registerKeybindings(app);
await app.run();State Management
Use a reducer pattern with typed actions. This is the single most impactful pattern for keeping Rezi apps maintainable.
Define Types
// src/types.ts
export type Todo = { id: string; text: string; done: boolean };
export type State = {
todos: Todo[];
selectedIndex: number;
filter: "all" | "active" | "done";
input: string;
};
export type Action =
| { type: "addTodo"; text: string }
| { type: "toggleTodo"; index: number }
| { type: "removeTodo"; index: number }
| { type: "setFilter"; filter: State["filter"] }
| { type: "setInput"; value: string }
| { type: "moveSelection"; direction: "up" | "down" };Implement the Reducer
// src/helpers/actions.ts
import type { State, Action } from "../types.js";
export const initialState: State = {
todos: [],
selectedIndex: 0,
filter: "all",
input: "",
};
export function reduce(state: State, action: Action): State {
switch (action.type) {
case "addTodo":
if (!action.text.trim()) return state;
return {
...state,
todos: [...state.todos, { id: Date.now().toString(), text: action.text.trim(), done: false }],
input: "",
};
case "toggleTodo":
return {
...state,
todos: state.todos.map((t, i) =>
i === action.index ? { ...t, done: !t.done } : t
),
};
case "removeTodo":
return {
...state,
todos: state.todos.filter((_, i) => i !== action.index),
selectedIndex: Math.min(state.selectedIndex, state.todos.length - 2),
};
case "setFilter":
return { ...state, filter: action.filter, selectedIndex: 0 };
case "setInput":
return { ...state, input: action.value };
case "moveSelection": {
const maxIndex = state.todos.length - 1;
const delta = action.direction === "up" ? -1 : 1;
return {
...state,
selectedIndex: Math.max(0, Math.min(state.selectedIndex + delta, maxIndex)),
};
}
}
}
export function createDispatch(app: { update: (fn: (s: State) => State) => void }) {
return function dispatch(action: Action) {
app.update(s => reduce(s, action));
};
}Benefits of this approach:
- Testable --
reduce()is a pure function; test it with plain assertions, no UI needed - Predictable -- Every state transition is an explicit action
- Debuggable -- Log dispatched actions to trace what happened
- Composable -- Multiple UI elements (buttons, keys, modes) dispatch the same actions
Screen Architecture
Each screen is a pure view function that takes state and returns a VNode tree. Screens should not call app.update() directly; they receive a dispatch function or wire callbacks through props.
// src/screens/main.ts
import { ui, rgb } from "@rezi-ui/core";
import type { App } from "@rezi-ui/core";
import type { State, Action } from "../types.js";
import { reduceCliState } from "../helpers/state.js";
export function mainScreen(app: App<State>) {
const dispatch = (action: Action) => app.update(s => reduceCliState(s, action));
return (state: State) => {
const { todos, selectedIndex, filter, input } = state;
const filtered = todos.filter(t =>
filter === "all" ? true : filter === "active" ? !t.done : t.done
);
return ui.column({ p: 1, gap: 1 }, [
// Header
ui.text("Todo List", { style: { fg: rgb(120, 200, 255), bold: true } }),
// Filter tabs
ui.row({ gap: 2 }, [
ui.button({
id: "filter-all",
label: filter === "all" ? "[All]" : "All",
onPress: () => dispatch({ type: "setFilter", filter: "all" }),
}),
ui.button({
id: "filter-active",
label: filter === "active" ? "[Active]" : "Active",
onPress: () => dispatch({ type: "setFilter", filter: "active" }),
}),
ui.button({
id: "filter-done",
label: filter === "done" ? "[Done]" : "Done",
onPress: () => dispatch({ type: "setFilter", filter: "done" }),
}),
]),
// Todo items
ui.box({ title: `Items (${filtered.length})`, p: 1 }, [
filtered.length === 0
? ui.text("No items", { style: { dim: true } })
: ui.column(
{ gap: 0 },
filtered.map((todo, i) =>
ui.text(
`${i === selectedIndex ? "> " : " "}${todo.done ? "[x]" : "[ ]"} ${todo.text}`,
{ key: todo.id, style: { dim: todo.done } }
)
),
),
]),
// Input row
ui.row({ gap: 1 }, [
ui.input({
id: "new-todo",
value: input,
onInput: v => dispatch({ type: "setInput", value: v }),
}),
ui.button({
id: "add",
label: "Add",
onPress: () => dispatch({ type: "addTodo", text: input }),
}),
]),
]);
};
}Notice that mainScreen() returns a closure. The outer function captures app for dispatch wiring; the inner function is the pure view that receives state each render.
Widget Composition
Use ui.* for built-in widgets and defineWidget() for reusable custom components with local state.
Simple Composition (No Local State)
For stateless reusable pieces, plain functions returning VNodes are sufficient:
// src/widgets/header.ts
import { ui, rgb } from "@rezi-ui/core";
export function header(title: string, subtitle?: string) {
return ui.column({ gap: 0 }, [
ui.text(title, { style: { fg: rgb(120, 200, 255), bold: true } }),
...(subtitle ? [ui.text(subtitle, { style: { dim: true } })] : []),
ui.divider(),
]);
}Stateful Widgets with defineWidget()
When a component needs its own local state, use defineWidget():
// src/widgets/toggleSection.ts
import { defineWidget, ui } from "@rezi-ui/core";
type ToggleSectionProps = {
title: string;
children: VNode[];
key?: string;
};
export const ToggleSection = defineWidget<ToggleSectionProps>(
(props, ctx) => {
const [expanded, setExpanded] = ctx.useState(true);
return ui.column({ gap: 0 }, [
ui.button({
id: ctx.id("toggle"),
label: `${expanded ? "v" : ">"} ${props.title}`,
onPress: () => setExpanded(prev => !prev),
}),
...(expanded ? props.children : []),
]);
},
{ name: "ToggleSection" }
);
// Usage:
ui.column([
ToggleSection({
title: "Details",
children: [
ui.text("Line 1"),
ui.text("Line 2"),
],
}),
]);Key rules for defineWidget():
- Use
ctx.id("suffix")for all interactive widget IDs to prevent collisions between instances - Hooks must be called in the same order every render (no conditional hooks)
- Use
ctx.useAppState()to read app-level state from within a widget
Error Handling
Error Boundaries
Wrap risky subtrees with ui.errorBoundary() to prevent one broken widget from crashing your entire app:
ui.errorBoundary({
children: RiskyWidget({ data }),
fallback: error =>
ui.column({}, [
ui.errorDisplay(error.message, { title: error.code }),
ui.button({ id: "retry", label: "Retry", onPress: error.retry }),
]),
})Application-Level Error Handling
Use app.onEvent() to handle errors and other events at the app level:
app.onEvent(event => {
if (event.type === "error") {
app.update(s => ({
...s,
lastError: event.message,
showErrorToast: true,
}));
}
});Defensive State Updates
Guard your reducer against invalid states:
case "removeTodo": {
const newTodos = state.todos.filter((_, i) => i !== action.index);
return {
...state,
todos: newTodos,
selectedIndex: Math.max(0, Math.min(state.selectedIndex, newTodos.length - 1)),
};
}Keybindings
Centralize Keybindings
Keep all key mappings in a single file for discoverability and testability:
// src/helpers/keybindings.ts
import type { App } from "@rezi-ui/core";
import type { State, Action } from "../types.js";
import { reduce } from "./actions.js";
export function registerKeybindings(app: App<State>) {
const dispatch = (action: Action) => app.update(s => reduce(s, action));
app.keys({
// Navigation
j: () => dispatch({ type: "moveSelection", direction: "down" }),
k: () => dispatch({ type: "moveSelection", direction: "up" }),
// Actions
space: () =>
app.update(s => reduce(s, { type: "toggleTodo", index: s.selectedIndex })),
d: () =>
app.update(s => reduce(s, { type: "removeTodo", index: s.selectedIndex })),
// Filters
"1": () => dispatch({ type: "setFilter", filter: "all" }),
"2": () => dispatch({ type: "setFilter", filter: "active" }),
"3": () => dispatch({ type: "setFilter", filter: "done" }),
// App
q: () => app.stop(),
"ctrl+c": () => app.stop(),
});
}Use app.modes() for Vim-Style Input
When your app has distinct input modes (e.g., normal vs. insert):
app.modes({
normal: {
i: () => app.setMode("insert"),
"/": () => app.setMode("search"),
j: () => dispatch({ type: "moveSelection", direction: "down" }),
k: () => dispatch({ type: "moveSelection", direction: "up" }),
},
insert: {
escape: () => app.setMode("normal"),
},
search: {
escape: () => app.setMode("normal"),
enter: () => dispatch({ type: "executeSearch" }),
},
});Show Keybinding Help
Display available keybindings in the UI so users can discover them:
ui.text("j/k: navigate | space: toggle | d: delete | q: quit", {
style: { fg: rgb(100, 100, 100) },
})Testing
The reducer + pure screen architecture makes testing straightforward. Test each layer independently.
Test the Reducer
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { reduce, initialState } from "./helpers/actions.js";
describe("reduce", () => {
it("adds a todo", () => {
const next = reduce(initialState, { type: "addTodo", text: "Buy milk" });
assert.equal(next.todos.length, 1);
assert.equal(next.todos[0].text, "Buy milk");
assert.equal(next.todos[0].done, false);
});
it("ignores empty text", () => {
const next = reduce(initialState, { type: "addTodo", text: " " });
assert.equal(next.todos.length, 0);
});
it("toggles a todo", () => {
const withTodo = reduce(initialState, { type: "addTodo", text: "Test" });
const toggled = reduce(withTodo, { type: "toggleTodo", index: 0 });
assert.equal(toggled.todos[0].done, true);
});
it("clamps selection after removal", () => {
let state = reduce(initialState, { type: "addTodo", text: "A" });
state = reduce(state, { type: "addTodo", text: "B" });
state = { ...state, selectedIndex: 1 };
state = reduce(state, { type: "removeTodo", index: 1 });
assert.equal(state.selectedIndex, 0);
});
});Test Screen Functions
Screen view functions are pure -- pass in state, assert the returned VNode tree:
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { mainScreen } from "./screens/main.js";
describe("mainScreen", () => {
it("shows empty message when no todos", () => {
// Create a mock app for wiring
const updates: Array<(s: State) => State> = [];
const mockApp = { update: (fn: any) => updates.push(fn) } as any;
const view = mainScreen(mockApp);
const tree = view({ todos: [], selectedIndex: 0, filter: "all", input: "" });
// Assert tree structure -- inspect the VNode tree
assert.equal(tree.kind, "column");
});
});Test Keybindings
Verify that keybindings dispatch the expected actions:
import { describe, it } from "node:test";
import assert from "node:assert/strict";
describe("keybindings", () => {
it("dispatches moveSelection on j/k", () => {
const updates: Array<(s: State) => State> = [];
const mockApp = {
update: (fn: any) => updates.push(fn),
keys: (bindings: any) => { /* store bindings for testing */ },
stop: () => {},
} as any;
registerKeybindings(mockApp);
// Verify the correct bindings were registered
});
});Theming
Use Built-in Themes
Rezi ships with a default theme. Pass a custom theme or theme definition to createNodeApp():
import { createNodeApp } from "@rezi-ui/node";
import type { ThemeDefinition } from "@rezi-ui/core";
const darkTheme: ThemeDefinition = {
colors: {
fg: { r: 220, g: 220, b: 220 },
bg: { r: 20, g: 20, b: 30 },
primary: { r: 100, g: 180, b: 255 },
secondary: { r: 180, g: 100, b: 255 },
success: { r: 100, g: 220, b: 100 },
danger: { r: 255, g: 100, b: 100 },
warning: { r: 255, g: 200, b: 50 },
info: { r: 100, g: 200, b: 255 },
muted: { r: 100, g: 100, b: 100 },
border: { r: 60, g: 60, b: 80 },
},
};
const app = createNodeApp<State>({
initialState,
theme: darkTheme,
});Theme Switching
Store the active theme in state and recreate the theme object:
// src/theme.ts
import type { ThemeDefinition } from "@rezi-ui/core";
export const themes = {
dark: { colors: { /* ... */ } } satisfies ThemeDefinition,
light: { colors: { /* ... */ } } satisfies ThemeDefinition,
solarized: { colors: { /* ... */ } } satisfies ThemeDefinition,
} as const;
export type ThemeName = keyof typeof themes;NO_COLOR Support
createNodeApp() automatically detects the NO_COLOR environment variable and strips colors from the theme. You do not need to handle this manually.
Performance
Use useMemo for Expensive Computations
Inside defineWidget(), wrap expensive filtering or sorting with ctx.useMemo():
const filtered = ctx.useMemo(
() => items.filter(item => matchesQuery(item, query)).sort(compareFn),
[items, query]
);Provide key for Dynamic Lists
Always use key when rendering lists derived from data. This allows Rezi's reconciler to match items efficiently and preserve widget state:
// Good: keyed list
ui.column(
items.map(item => ui.text(item.name, { key: item.id }))
)
// Bad: unkeyed list (reconciler falls back to index matching)
ui.column(
items.map(item => ui.text(item.name))
)Use ui.virtualList() for Large Data Sets
For lists with hundreds or thousands of items, use ui.virtualList() to only render the visible portion:
ui.virtualList<LogEntry>({
id: "log-view",
items: logEntries, // Can be thousands of items
itemHeight: 1, // Each item is 1 row tall
height: 20, // Visible viewport is 20 rows
renderItem: (entry, index) =>
ui.text(`[${entry.timestamp}] ${entry.message}`, {
key: entry.id,
style: { fg: levelColor(entry.level) },
}),
})Minimize State Size
Keep your state flat and minimal. Deeply nested state leads to more spread operations and harder-to-maintain reducers. Extract computed values in the view function rather than storing them in state.
Batch Related Updates
Multiple app.update() calls in the same tick are batched into a single re-render. You do not need to combine them manually:
// These produce a single re-render
dispatch({ type: "setFilter", filter: "active" });
dispatch({ type: "moveSelection", direction: "up" });Summary
| Pattern | Why |
|---|---|
| Template project structure | Separation of concerns, testability |
| Reducer with typed actions | Pure, testable, debuggable state logic |
| Pure screen view functions | Predictable rendering, easy to test |
ui.* + defineWidget() | Type-safe composition with local state |
ui.errorBoundary() | Graceful failure isolation |
| Centralized keybindings | Discoverable, testable, no duplication |
| Separate reducer/screen/keybinding tests | Fast, focused, no UI harness needed |
| Theme definitions | Consistent styling, NO_COLOR support |
useMemo, keys, virtualList | Efficient rendering at scale |
Hooks Reference
Hooks are functions available on the WidgetContext (ctx) inside defineWidget render functions. They let composite widgets manage local state, run side effects, and access app-level data without bre...
Widget API Reference
Rezi ships a comprehensive built-in widget catalog. Every widget is a plain TypeScript function that returns a VNode -- the virtual-DOM node Rezi reconciles, lays out, and renders to the terminal.