Rezi applications follow a well-defined lifecycle from creation to disposal. Understanding this lifecycle is crucial for proper resource management and graceful shutdown.
Application Creation
Create an app with createNodeApp() (for Node.js/Bun):
import { createNodeApp } from '@rezi-ui/node' ;
import { ui } from '@rezi-ui/core' ;
type State = { count : number };
const app = createNodeApp < State >({
initialState: { count: 0 },
config: {
fpsCap: 30 , // Frame rate cap (default: 60)
maxEventBytes: 1048576 , // Event buffer size (default: 1 MiB)
rootPadding: 1 , // Padding around root widget
},
theme: myTheme , // Optional custom theme
});
Configuration Options
Initial application state. Must be a plain object.
Maximum frame rate (1-1000). Most TUIs work well at 30 FPS.
Event buffer size in bytes. Must match backend if using custom backends.
Padding (in cells) around the root widget.
Maximum concurrent frames allowed (1-4). Higher values improve throughput but increase latency.
config.themeTransitionFrames
Number of frames to animate theme transitions. Set to 10-20 for smooth color changes.
Custom theme. Defaults to defaultTheme.
Defining the View
Attach a view function that transforms state to VNodes:
app . view (( state : State ) =>
ui . page ({ p: 1 }, [
ui . text ( `Count: ${ state . count } ` ),
ui . button ({
id: 'increment' ,
label: '+1' ,
onPress : () => app . update ( s => ({ count: s . count + 1 }))
}),
])
);
app.view() must be called before app.start() and can only be called once (or use app.replaceView() to swap views at runtime).
View Function Requirements
Pure function : Same state → same VNode tree
No side effects : No API calls, timers, or I/O
Returns VNode : Must return a valid VNode (not null or undefined)
// ✅ GOOD: Pure view
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 (onPress, onInput, etc.)
Keybinding callbacks
useEffect hooks inside defineWidget
Starting the App
app.start()
Start the app without signal handling:
await app . start ();
console . log ( 'App is running' );
// Manually stop later
await app . stop ();
app . dispose ();
app.run()
Start the app with automatic signal handling (recommended):
await app . run ();
// Automatically handles SIGINT, SIGTERM, SIGHUP
// Returns when app stops
app.run() is equivalent to:
const signals = [ 'SIGINT' , 'SIGTERM' , 'SIGHUP' ];
for ( const signal of signals ) {
process . on ( signal , async () => {
await app . stop ();
app . dispose ();
process . exit ( 0 );
});
}
await app . start ();
Always call app.dispose() after stopping to release native resources. Failing to dispose can leak memory and file descriptors.
State Management
app.update()
Update application state:
// Functional update (recommended)
app . update ( prev => ({ ... prev , count: prev . count + 1 }));
// Direct replacement
app . update ({ count: 0 });
Update batching: Multiple update() calls in the same event loop tick are coalesced into a single render:
app . update ( s => ({ ... s , count: s . count + 1 }));
app . update ( s => ({ ... s , name: 'Alice' }));
app . update ( s => ({ ... s , email: '[email protected] ' }));
// Only 1 render happens, not 3
Reducer Pattern (Recommended)
For non-trivial apps, use a reducer pattern :
// types.ts
type State = { count : number ; items : string [] };
type Action =
| { type : 'increment' }
| { type : 'addItem' ; text : string }
| { type : 'removeItem' ; index : number };
// state.ts
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 ) };
}
}
// main.ts
function dispatch ( action : Action ) {
app . update ( s => reduce ( s , action ));
}
app . keys ({
'+' : () => dispatch ({ type: 'increment' }),
'a' : () => dispatch ({ type: 'addItem' , text: 'New item' }),
});
Benefits:
Pure, testable state logic
Type-safe actions
Centralized state transitions
Easy to debug (log actions)
State Invariants
Never mutate state directly: // ❌ BAD: Mutation
app . update ( s => {
s . count ++ ; // Mutates state!
return s ;
});
// ✅ GOOD: Immutable update
app . update ( s => ({ ... s , count: s . count + 1 }));
Mutating state breaks reconciliation and causes stale renders.
Cannot call app.update() during render: // ❌ BAD: Update during render
app . view ( state => {
app . update ({ count: 5 }); // ERROR: ZRUI_UPDATE_DURING_RENDER
return ui . text ( '...' );
});
Move state updates to event handlers or useEffect.
Keybindings
app.keys()
Register global keybindings:
app . keys ({
'q' : () => app . stop (),
'ctrl+c' : () => app . stop (),
'ctrl+s' : () => saveData (),
'+' : () => app . update ( s => ({ ... s , count: s . count + 1 })),
'-' : () => app . update ( s => ({ ... s , count: s . count - 1 })),
});
Chord sequences (Vim-style):
app . keys ({
'g g' : () => scrollToTop (), // Press 'g' twice
'g G' : () => scrollToBottom (), // Press 'g' then 'G'
});
Modifier keys:
app . keys ({
'ctrl+k' : () => deleteForward (),
'alt+b' : () => backwardWord (),
'shift+tab' : () => focusPrevious (),
'meta+q' : () => app . stop (), // Cmd on Mac, Win on Windows
});
app.modes()
Modal keybinding modes (like Vim):
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 (),
},
});
Mode switching:
app . setMode ( 'normal' ); // Switch to normal mode
app . getMode (); // Get current mode
Event Handling
app.onEvent()
Listen to all application events:
const removeListener = app . onEvent ( event => {
if ( event . kind === 'action' ) {
console . log ( 'Action:' , event );
}
if ( event . kind === 'engine' ) {
console . log ( 'Engine event:' , event . event );
}
if ( event . kind === 'fatal' ) {
console . error ( 'Fatal error:' , event . code , event . detail );
}
});
// Cleanup
removeListener ();
Event types:
Action Events
Engine Events
Fatal Errors
{
kind : 'action' ,
type : 'press' | 'input' | 'toggle' | 'select' | 'change' | 'scroll' ,
id : string ,
// ... type-specific fields
}
app.onFocusChange()
Track focus changes:
app . onFocusChange ( focusInfo => {
console . log ( 'Focused widget:' , focusInfo . id );
console . log ( 'Focus metadata:' , focusInfo . metadata );
});
Theme Management
app.setTheme()
Dynamically change the theme:
import { darkTheme , lightTheme } from './themes' ;
let isDark = false ;
app . keys ({
't' : () => {
isDark = ! isDark ;
app . setTheme ( isDark ? darkTheme : lightTheme );
},
});
With animated transitions :
const app = createNodeApp ({
initialState: {},
config: {
themeTransitionFrames: 15 , // Animate over 15 frames
},
});
app . setTheme ( newTheme ); // Smoothly interpolates colors
Stopping and Cleanup
app.stop()
Gracefully stop the application:
await app . stop ();
// Terminal is restored to normal mode
// No new frames will be rendered
app.dispose()
Release all native resources:
app . dispose ();
// Backend is disposed
// Native engine is freed
// Cannot restart after disposal
Proper shutdown sequence:
try {
await app . stop ();
} finally {
app . dispose ();
}
State Machine
The app follows this state machine:
State transitions:
Created : App initialized, ready for start()
Running : Event loop active, rendering frames
Stopped : Gracefully stopped, can restart
Faulted : Unrecoverable error, must dispose
Disposed : Resources released, cannot restart
Once Disposed or Faulted , the app cannot be restarted. Create a new app instance instead.
Error Handling
Top-Level View Errors
If the view function throws, Rezi displays an error screen with retry/quit options:
app . view ( state => {
if ( state . data === null ) {
throw new Error ( 'Data not loaded' ); // Caught by Rezi
}
return ui . text ( state . data );
});
Press R to retry, Q to quit.
Fatal Errors
Fatal errors transition the app to Faulted state:
app . onEvent ( event => {
if ( event . kind === 'fatal' ) {
console . error ( 'Fatal:' , event . code , event . detail );
// App is now Faulted
// Backend is automatically stopped and disposed
}
});
Common fatal codes:
ZRUI_BACKEND_ERROR: Backend operation failed
ZRUI_PROTOCOL_ERROR: Invalid binary protocol
ZRUI_USER_CODE_THROW: Unhandled exception in user code
Render Loop Model
The event loop follows this flow:
┌─────────────────────────────────────┐
│ Poll events from backend │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Parse event batch (ZREV) │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Route events to widgets │
│ Fire keybindings │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Batch state updates │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Commit state │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Call view(state) → VNode tree │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Reconcile (diff VNode trees) │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Compute layout │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Render to drawlist (ZRDL) │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Submit frame to backend │
└──────────────┬──────────────────────┘
↓
┌─────────────────────────────────────┐
│ Engine diffs and writes ANSI │
└─────────────────────────────────────┘
Frame coalescing: If multiple state updates occur before the previous frame completes, they are coalesced into a single render.
Back-pressure handling: If the backend falls behind, the app caps in-flight frames to prevent memory growth.
Best Practices
Use fpsCap: 30 for most apps
60 FPS is rarely necessary for TUIs and doubles CPU usage. 30 FPS is smooth enough for most interactions.
Define all keybindings in one place (e.g., keybindings.ts) and dispatch actions rather than performing logic inline.
Use reducer pattern for state
Pure reducer functions are testable, debuggable, and easier to reason about than scattered app.update() calls.
Always dispose after stop
Failing to dispose leaks native resources. Use try/finally to ensure cleanup.
Handle signals gracefully
Use app.run() for automatic signal handling, or manually handle SIGINT/SIGTERM for graceful shutdown.
Example: Complete Lifecycle
import { createNodeApp } from '@rezi-ui/node' ;
import { ui } from '@rezi-ui/core' ;
type State = { count : number };
type Action = { type : 'increment' } | { type : 'decrement' };
function reduce ( state : State , action : Action ) : State {
switch ( action . type ) {
case 'increment' : return { count: state . count + 1 };
case 'decrement' : return { count: state . count - 1 };
}
}
const app = createNodeApp < State >({
initialState: { count: 0 },
config: { fpsCap: 30 },
});
function dispatch ( action : Action ) {
app . update ( s => reduce ( s , action ));
}
app . view ( state =>
ui . page ({ p: 1 }, [
ui . panel ( 'Counter' , [
ui . text ( `Count: ${ state . count } ` ),
ui . actions ([
ui . button ({
id: 'increment' ,
label: '+1' ,
intent: 'primary' ,
onPress : () => dispatch ({ type: 'increment' }),
}),
ui . button ({
id: 'decrement' ,
label: '-1' ,
intent: 'secondary' ,
onPress : () => dispatch ({ type: 'decrement' }),
}),
]),
]),
])
);
app . keys ({
'q' : () => app . stop (),
'+' : () => dispatch ({ type: 'increment' }),
'-' : () => dispatch ({ type: 'decrement' }),
});
app . onEvent ( event => {
if ( event . kind === 'fatal' ) {
console . error ( 'Fatal:' , event . code , event . detail );
}
});
try {
await app . run ();
} finally {
app . dispose ();
}
Next Steps
Widget Catalog Explore all built-in widgets
Composition Build reusable components
Examples Real-world application examples
Testing Write tests for your TUI apps