Concepts
Understanding Rezi's core concepts will help you build effective terminal applications. This page covers the mental model, the key abstractions, and how they fit together.
Understanding Rezi's core concepts will help you build effective terminal applications. This page covers the mental model, the key abstractions, and how they fit together.
VNode Trees (Declarative UI)
Rezi applications describe their UI as a tree of virtual nodes (VNodes). You never write terminal escape codes or manage cursor positions directly. Instead, you declare what the UI should look like, and Rezi figures out how to render it.
app.view(state =>
ui.column({ gap: 1 }, [
ui.text("Hello, World!"),
ui.button({ id: "ok", label: "OK" }),
])
);Each call to a ui.* function returns a VNode -- a lightweight plain object with a kind field and typed props. The ui.* helpers are the recommended way to build VNode trees. They provide type safety and a clean API without requiring you to construct discriminated union objects by hand.
Key Properties
key for reconciliation
: When rendering dynamic lists, the key prop helps Rezi track which items changed, were added, or were removed. Always provide keys for list items derived from data:
ui.column(
items.map(item => ui.text(item.name, { key: item.id }))
)id for interactivity
: Focusable widgets require an id prop. This is used for focus management (Tab/Shift+Tab navigation), event routing (which button was pressed), and focus restoration after modal closes:
ui.button({ id: "submit", label: "Submit" })
ui.input({ id: "email", value: state.email })State-Driven Rendering
Rezi follows a strict unidirectional data flow:
State --> View --> VNode Tree --> Render
^ |
+--- Events <-- User Input <------+Your application state is the single source of truth. The view function transforms state into a VNode tree. User interactions produce events that update state, which triggers a new render cycle.
State Updates
State changes through app.update():
// Functional update (recommended -- avoids stale closures)
app.update(prev => ({ ...prev, count: prev.count + 1 }));
// Direct replacement
app.update({ count: 0 });Updates are batched and coalesced. Multiple update() calls in the same event loop tick produce a single re-render, so you do not need to worry about redundant renders when dispatching several updates in a row.
Pure View Functions
The view function should be pure -- given the same state, it should return the same VNode tree:
// Good: Pure function, same input produces same output
app.view(state => ui.text(`Count: ${state.count}`));
// Bad: Side effects in view
app.view(state => {
console.log("Rendering..."); // Side effect
fetchData(); // Side effect
return ui.text(`Count: ${state.count}`);
});Side effects belong in event handlers, keybinding callbacks, or useEffect hooks inside defineWidget().
Reducer Pattern (Recommended)
For non-trivial apps, use a reducer pattern to manage state transitions. This keeps your state logic pure, testable, and separate from your UI:
type State = { count: number; items: string[] };
type Action =
| { type: "increment" }
| { type: "addItem"; text: string }
| { type: "removeItem"; index: number };
function reduce(state: State, action: Action): State {
switch (action.type) {
case "increment": return { ...state, count: state.count + 1 };
case "addItem": return { ...state, items: [...state.items, action.text] };
case "removeItem": return { ...state, items: state.items.filter((_, i) => i !== action.index) };
}
}
function dispatch(action: Action) {
app.update(s => reduce(s, action));
}The reducer is a plain function. You can test it in isolation with no framework dependencies.
Widget Composition via ui.*
The ui namespace provides factory functions for every built-in widget. These are organized into categories:
Structural Widgets
Container and layout: ui.box(), ui.row(), ui.column(), ui.spacer(), ui.divider(), ui.grid()
Content Widgets
Display information: ui.text(), ui.richText(), ui.icon(), ui.badge(), ui.callout()
Interactive Widgets
Accept user input: ui.button(), ui.input(), ui.checkbox(), ui.select(), ui.radioGroup(), ui.slider()
Data Widgets
Display structured data: ui.table(), ui.virtualList(), ui.tree()
Overlay Widgets
Modal interfaces: ui.modal(), ui.dropdown(), ui.toast(), ui.layers()
Feedback Widgets
Loading and error states: ui.spinner(), ui.progress(), ui.skeleton(), ui.errorDisplay(), ui.errorBoundary()
Custom Widgets with defineWidget()
For reusable components with local state and lifecycle, use defineWidget():
import { defineWidget, ui } from "@rezi-ui/core";
type CounterProps = { initial: number; key?: string };
const Counter = defineWidget<CounterProps>(
(props, ctx) => {
const [count, setCount] = ctx.useState(props.initial);
return ui.row({ gap: 1 }, [
ui.text(`Count: ${count}`),
ui.button({
id: ctx.id("inc"),
label: "+1",
onPress: () => setCount(c => c + 1),
}),
]);
},
{ name: "Counter" }
);
// Usage in a view:
app.view(() =>
ui.column([
Counter({ initial: 0 }),
Counter({ initial: 10, key: "counter-2" }),
])
);defineWidget() gives each instance its own WidgetContext with hooks:
ctx.useState()-- Local state that persists across rendersctx.useRef()-- Mutable ref without triggering re-renderctx.useMemo()-- Memoize expensive computationsctx.useCallback()-- Stable callback referencesctx.useEffect()-- Side effects with cleanupctx.useAppState()-- Select a slice of app statectx.id()-- Generate scoped IDs to prevent collisions
The Render Pipeline
When state changes, Rezi runs through a multi-phase pipeline:
State --> view(state) --> VNode Tree --> Reconcile --> Layout --> Render --> Drawlist --> Backend- View -- Your view function produces a new VNode tree
- Reconcile -- The new tree is diffed against the previous tree using keys and structural matching
- Layout -- Widget sizes and positions are computed (flexbox-inspired model)
- Render -- The layout tree is walked to produce draw commands
- Drawlist -- Commands are encoded into the ZRDL binary format
- Backend -- The native engine processes the drawlist and paints the terminal
You rarely need to think about these phases directly. The framework handles them automatically, including optimizations like layout stability detection (skip relayout when the tree structure has not changed) and update batching.
Hooks for Stateful Widgets
Hooks are available inside defineWidget() render functions through the WidgetContext. They follow the same rules as React hooks -- call them unconditionally and in the same order every render.
const SearchList = defineWidget<SearchListProps, AppState>(
(props, ctx) => {
const [query, setQuery] = ctx.useState("");
const items = ctx.useAppState(s => s.items);
// Memoize filtered results
const filtered = ctx.useMemo(
() => items.filter(item => item.name.includes(query)),
[items, query]
);
// Stable callback for input handler
const onInput = ctx.useCallback(
(value: string) => setQuery(value),
[]
);
return ui.column({ gap: 1 }, [
ui.input({ id: ctx.id("search"), value: query, onInput }),
...filtered.map(item =>
ui.text(item.name, { key: item.id })
),
]);
},
{ name: "SearchList" }
);Additional utility hooks are exported from @rezi-ui/core:
useDebounce(ctx, value, delayMs)-- Debounce a valueusePrevious(ctx, value)-- Track the previous render's valueuseAsync(ctx, asyncFn, deps)-- Manage async data loading
Keybinding System
Rezi provides two layers of keyboard handling:
Global Keybindings
Register application-wide shortcuts with app.keys():
app.keys({
"ctrl+s": () => save(),
"ctrl+q": () => app.stop(),
q: () => app.stop(),
"g g": () => scrollToTop(), // Chord: press g twice
});Key names support modifiers (ctrl, alt, shift, meta) and chord sequences (space-separated keys pressed in sequence).
Modal / Vim-Style Modes
For applications with distinct input modes, use app.modes():
app.modes({
normal: {
i: () => app.setMode("insert"),
j: () => moveCursorDown(),
k: () => moveCursorUp(),
"/": () => app.setMode("search"),
},
insert: {
escape: () => app.setMode("normal"),
},
search: {
escape: () => app.setMode("normal"),
enter: () => executeSearch(),
},
});Widget-Level Events
Individual widgets handle input through callback props:
ui.button({
id: "submit",
label: "Submit",
onPress: () => handleSubmit(),
});
ui.input({
id: "name",
value: state.name,
onInput: value => app.update(s => ({ ...s, name: value })),
onBlur: () => validate("name"),
});Recommended Practice
Centralize keybindings in a dedicated file and dispatch actions rather than performing logic inline. See Recommended Patterns for the full pattern.
Focus Model
Rezi manages focus automatically through keyboard and mouse input:
Tab and Mouse Navigation
Tab moves focus forward through focusable widgets. Shift+Tab moves backward. Clicking any focusable widget with the mouse also moves focus to it. See Mouse Support for details.
Focus Zones
Group widgets into focus zones for organized Tab navigation:
ui.column({}, [
ui.focusZone({ id: "toolbar" }, [
ui.button({ id: "save", label: "Save" }),
ui.button({ id: "load", label: "Load" }),
]),
ui.focusZone({ id: "content" }, [
ui.input({ id: "name", value: "" }),
ui.input({ id: "email", value: "" }),
]),
])Focus Traps
Constrain focus within a region (useful for modals):
ui.focusTrap({ id: "modal-trap", active: true }, [
ui.button({ id: "ok", label: "OK" }),
ui.button({ id: "cancel", label: "Cancel" }),
])Deterministic Rendering
Rezi is designed so that the same initial state plus the same sequence of input events produces the same frames and routed events. This determinism is achieved through:
- Version-pinned Unicode -- Text measurement uses a pinned Unicode version; the same string always measures to the same cell width
- Strict binary protocols -- The ZRDL (drawlist) and ZREV (event batch) formats are versioned and validated
- Locked update contract -- Updates during render throw
ZRUI_UPDATE_DURING_RENDER; reentrant calls throwZRUI_REENTRANT_CALL
Package Architecture
Rezi uses a layered architecture with strict boundaries:
+-------------------------------------+
| Your Application |
+-------------------------------------+
|
v
+-------------------------------------+
| @rezi-ui/core |
| (Runtime-agnostic TypeScript) |
| Widgets, Layout, Themes |
| Forms, Keybindings, Focus |
| Protocol builders/parsers |
+-------------------------------------+
|
v
+-------------------------------------+
| @rezi-ui/node |
| (Node.js/Bun Runtime Integration) |
| Worker threads, Event loop |
+-------------------------------------+
|
v
+-------------------------------------+
| @rezi-ui/native |
| (N-API Addon) |
| Zireael C engine binding |
| Terminal I/O |
+-------------------------------------+Portability: @rezi-ui/core contains no Node.js-specific APIs. It could theoretically run in any JavaScript runtime.
Testability: Core logic can be tested without a terminal or native addon.
Binary Boundary: The native engine communicates through versioned binary formats, enabling a stable ABI and language interop.
Next Steps
- Recommended Patterns - Best practices for production apps
- Lifecycle & Updates - State management in depth
- Layout - Spacing, alignment, and constraints
- Input & Focus - Keyboard and mouse navigation
- Mouse Support - Click, scroll, and drag interactions
- Widget Catalog - Complete widget reference