Lifecycle & Updates
This guide explains how a Rezi app moves through its lifecycle, how updates are committed, and what “deterministic scheduling” means in practice.
This guide explains how a Rezi app moves through its lifecycle, how updates are committed, and what “deterministic scheduling” means in practice.
createNodeApp (recommended)
Apps are usually created through createNodeApp from @rezi-ui/node:
import { ui } from "@rezi-ui/core";
import { createNodeApp } from "@rezi-ui/node";
type State = { count: number };
const app = createNodeApp<State>({
initialState: { count: 0 },
config: { fpsCap: 60, maxEventBytes: 1 << 20, useV2Cursor: false },
});
app.view((state) => ui.text(`Count: ${state.count}`));
await app.run();createNodeApp keeps app/backend cursor protocol, event caps, and fps knobs in
sync by construction.
Use run() for batteries-included lifecycle wiring in Node/Bun apps:
run()wrapsstart()- registers
SIGINT/SIGTERM/SIGHUP - on signal:
stop()thendispose()best-effort, then exits with code0
Use start() directly when you need manual signal/process control.
State machine diagram
View vs draw
Rezi supports two render modes (exactly one is active):
- Widget mode:
app.view((state) => VNode)— declarative widget tree - Raw mode:
app.draw((g) => void)— low-level draw API escape hatch
Set one mode before calling start(). Switching modes while running is rejected deterministically.
Update patterns
State is owned by the app. You update it via app.update(...):
// Functional update (recommended)
app.update((prev) => ({ ...prev, count: prev.count + 1 }));
// Replace state directly
app.update({ count: 0 });Patterns that keep updates predictable:
- keep
view(state)pure (no I/O, no timers, no mutations) - do work in event handlers, then call
update(...) - derive display values from state inside the view
Commit points
Updates are queued and applied at deterministic commit points:
- after a backend event batch is dispatched, and
- after an “explicit user turn” when you call
update()outside event dispatch
That means multiple update() calls in one turn are applied FIFO and result in a single committed state for the next render.
Re-entrancy
To prevent hidden feedback loops, the runtime enforces strict rules:
- calling
update()during the render pipeline throwsZRUI_UPDATE_DURING_RENDER - calling app APIs from inside an updater function throws
ZRUI_REENTRANT_CALL
If you need to trigger a follow-up update based on a render result, schedule it from an event handler or effect (composite widget) rather than inside view.
Event handling
You can handle events in three layers:
Widget callbacks (recommended)
ui.button({
id: "inc",
label: "+1",
onPress: () => app.update((s) => ({ ...s, count: s.count + 1 })),
});Global keybindings
app.keys({
"q": () => app.stop(),
"ctrl+c": () => app.stop(),
"g g": (ctx) => ctx.update((s) => ({ ...s, count: 0 })),
});Global event stream
const unsubscribe = app.onEvent((ev) => {
if (ev.kind === "fatal") console.error(ev.code, ev.message);
});
// later: unsubscribe()Frame coalescing
Rezi coalesces work:
- multiple updates in a single turn produce one commit
- rendering occurs after the commit
- in-flight submissions are bounded by
config.maxFramesInFlight(default1, clamped to1..4) - interactive input (
key/text/paste/mouse) gets a short urgent burst that allows one additional in-flight frame - tick-driven animation updates are bounded (spinner cadence is capped to avoid repaint storms)
This keeps runtime behavior bounded and prevents unbounded “render storms”.
Error handling
There are two main classes of errors:
- Synchronous misuse errors: thrown immediately from the API call (invalid state, update during render, etc.)
- Asynchronous fatal errors: emitted to
onEventhandlers as{ kind: "fatal", ... }, then the app transitions toFaultedand the backend is stopped/disposed best-effort
Render-throw behavior in widget mode is resilience-first:
ui.errorBoundary(...)can isolate subtree throws and render a fallback without faulting the app- top-level
view()throws render a built-in diagnostic screen (code/message/stack) withRretry andQquit controls - these recoverable view errors do not transition the app to
Faulted
If you register onEvent, treat emitted fatal events as terminal for that app instance.
Runtime error codes
Deterministic violations throw ZrUiError with a code:
ZRUI_INVALID_STATE: API called in the wrong app stateZRUI_MODE_CONFLICT: bothviewanddrawconfigured (or conflicting mode)ZRUI_NO_RENDER_MODE:start()called withoutviewordrawZRUI_REENTRANT_CALL: runtime API called re-entrantlyZRUI_UPDATE_DURING_RENDER:update()called during renderZRUI_DUPLICATE_KEY: duplicate VNodekeyamong siblingsZRUI_DUPLICATE_ID: duplicate widgetidfor focus routingZRUI_INVALID_PROPS: widget props failed validation (includesZRUI_MAX_DEPTHdetails for overly deep composite/layout nesting)ZRUI_PROTOCOL_ERROR: backend protocol parse/validation failureZRUI_DRAWLIST_BUILD_ERROR: drawlist build failureZRUI_BACKEND_ERROR: backend reported a fatal errorZRUI_USER_CODE_THROW: user callback threw an exception
See the API reference for the ZrUiErrorCode type and related helpers.
Cleanup
stop()leaves the app in a safe stopped state; you canstart()againdispose()releases resources and is idempotent (terminal state)run()is recommended for Node/Bun CLIs so signal shutdown and terminal cleanup are handled automaticallyonEvent(...)returns an unsubscribe function; call it when your app no longer needs the handler
Related
- Concepts - Pure view, VNodes, and reconciliation
- Performance - Why coalescing and keys matter
- Node/Bun backend - Runtime/backend integration details
Next: Layout.
Runtime & Layout
Rezi's runtime turns state into frames through a deterministic pipeline. This page is an index for the runtime internals and the layout model used across widgets.
Routing
Rezi's page router handles application-level screen navigation (Home, Logs, Settings, Detail) while the internal runtime router continues handling widget-level navigation (tabs, dropdowns, tree, vi...