Lifecycle & Updates¶
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: 30, maxEventBytes: 1 << 20 },
});
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 - if signal handlers cannot be registered in the current runtime,
run()still starts the app and resolves once startup completes
Use start() directly when you need manual signal/process control.
State machine diagram¶
stateDiagram-v2
[*] --> Created
Created --> Running: start()
Running --> Stopped: stop()
Stopped --> Running: start()
Created --> Disposed: dispose()
Running --> Disposed: dispose()
Stopped --> Disposed: dispose()
Running --> Faulted: fatal error
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.
Hot State-Preserving Reload (HSR)¶
Rezi supports development-time view hot swapping without process restarts.
app.view(fn)remains the initial setup API (Created/Stoppedstates)app.replaceView(fn)swaps the active widget view whileRunning, and also updates the configured widget view inCreated/Stoppedapp.replaceRoutes(routes)swaps route definitions for route-managed apps whileRunning, and also updates the configured route table inCreated/Stopped
When a new view function is applied, Rezi keeps:
- committed app state (
app.update(...)data) - widget instance local state (
defineWidgethooks with stable keys/ids) - focus routing state (when focused widget id still exists)
- widget-local editor/focus metadata (for stable widget ids)
import { ui } from "@rezi-ui/core";
import { createNodeApp } from "@rezi-ui/node";
const app = createNodeApp({
initialState: { count: 0 },
hotReload: {
viewModule: new URL("./screens/main-screen.ts", import.meta.url),
moduleRoot: new URL("./src", import.meta.url),
},
});
app.view((state) => ui.text(`count=${String(state.count)}`));
await app.run();
Route-managed apps can hot-swap route tables instead:
const app = createNodeApp({
initialState,
routes,
initialRoute: "home",
hotReload: {
routesModule: new URL("./screens/index.ts", import.meta.url),
moduleRoot: new URL("./src", import.meta.url),
resolveRoutes: (moduleNs) => {
const routesExport = (moduleNs as { routes?: unknown }).routes;
if (!Array.isArray(routesExport)) throw new Error("Expected `routes` array export");
return routesExport;
},
},
});
HSR is intentionally rejected for raw draw mode (app.draw(...)).
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.detail);
});
// later: unsubscribe()
Extended action model¶
UiEvent action payloads include more than "press" and "input".
Current routed action types include:
"press""input""select""rowPress""toggle""change""activate""scroll"
Widget-local callbacks (onSelect, onChange, onRowPress, etc.) still fire as before.
In addition, these actions also flow through app.onEvent(...), which enables
cross-cutting middleware, logging, analytics, and undo stacks.
app.onEvent((ev) => {
if (ev.kind === "action" && ev.action === "toggle") {
console.log(`Checkbox ${ev.id} toggled to ${ev.checked}`);
}
});
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.