Ink to Rezi Migration¶
This guide maps common Ink mental models to Rezi and gives practical migration recipes.
If you want to keep the Ink component/hook model and only switch runtime backend first, use Ink to Ink-Compat Migration instead.
Read this first¶
Rezi is not a drop-in replacement for Ink.
- Rezi does not promise API or behavior compatibility with Ink.
- Migration is intentional: move patterns, not component names.
This is by design. Rezi prioritizes deterministic rendering, deterministic input routing, and deterministic layout in terminal cells.
Mental model map¶
| Ink mental model | Rezi mental model | Why this is different |
|---|---|---|
render(<App />) starts a React tree |
createNodeApp(...) + app.view((state) => VNode) + app.start() |
Rezi runs a runtime-owned state/render pipeline instead of React reconciliation |
| Re-render timing follows React scheduling | Re-renders happen at deterministic commit points after app.update(...)/event batches |
Same input + state transitions produce the same frame sequence |
| Hooks live in React components | Stateful reusable widgets use defineWidget((props, ctx) => ...) with ctx.useState/useRef/useEffect/useAppState |
Keeps root view pure while still allowing local widget state |
Input usually handled with useInput and component focus assumptions |
Input routes through stable widget ids, focus order, app.keys(), app.modes(), focusZone, focusTrap |
Event routing and focus are explicit and deterministic |
| Yoga/flexbox layout expectations | Cell-based layout via ui.row, ui.column, ui.box, width/height/flex, p/m/gap |
Terminal layout is measured in character cells, not pixels |
What's different (on purpose)¶
- No compatibility promise: Rezi does not emulate Ink internals or component semantics.
- Deterministic scheduling: queued updates are coalesced at commit points; render-time updates throw.
- Deterministic focus/input: stable
id+ documented routing order avoids timing-dependent behavior. - Cell-based layout: widths/heights/overflow are resolved in terminal cells for predictable output.
Recipes¶
1) Layout: Yoga-style intent to Rezi cell layout¶
Use stacks (row/column) plus explicit cell constraints.
app.view((state) =>
ui.row({ gap: 1 }, [
ui.box({ width: 24, border: "rounded", p: 1, title: "Sidebar" }, [
ui.column({ gap: 1 }, state.menu.map((item) => ui.text(item, { key: item }))),
]),
ui.box({ flex: 1, border: "single", p: 1, title: "Content" }, [
ui.text(state.title, { style: { bold: true } }),
ui.text(state.body, { textOverflow: "ellipsis", maxWidth: 60 }),
]),
])
);
Migration notes:
- width/height numbers are terminal cells.
- flex distributes remaining space.
- p, m, and gap are cell-based spacing.
2) Lists and virtualization¶
Small lists: map into ui.column with stable key.
Large lists: switch to ui.virtualList.
ui.virtualList({
id: "results",
items: state.rows,
itemHeight: 1,
overscan: 3,
renderItem: (row, _i, focused) =>
ui.text(`${focused ? ">" : " "} ${row.name}`, {
key: row.id,
style: focused ? { bold: true } : undefined,
}),
});
3) Tables¶
Use ui.table for sorting, selection, and built-in virtualization.
ui.table({
id: "users",
columns: [
{ key: "name", header: "Name", flex: 1, sortable: true, overflow: "middle" },
{ key: "role", header: "Role", width: 12 },
],
data: state.users,
getRowKey: (u) => u.id,
selection: state.selection,
selectionMode: "multi",
onSelectionChange: (keys) => app.update((s) => ({ ...s, selection: keys })),
onSort: (column, direction) => app.update((s) => ({ ...s, sortColumn: column, sortDirection: direction })),
virtualized: true,
});
4) Text styling and truncation¶
ui.text handles style + deterministic cell-aware truncation. Four overflow modes are available: "clip" (default), "ellipsis", "middle", and "start".
import { rgb } from "@rezi-ui/core";
ui.column({ gap: 1 }, [
ui.text("Build failed", { style: { fg: rgb(255, 110, 110), bold: true } }),
ui.text(state.path, { textOverflow: "middle", maxWidth: 40 }),
ui.text(state.longPath, { textOverflow: "start" }), // keeps tail, e.g. "…src/index.ts"
]);
4b) Layout measurement¶
Ink's measureElement ref has a direct equivalent: app.measureElement(id) returns the computed Rect ({ x, y, w, h }) for any widget by its id, or null if not found.
const rect = app.measureElement("sidebar");
if (rect) console.log(`sidebar is ${rect.w}x${rect.h} at (${rect.x},${rect.y})`);
5) Forms¶
Use controlled input widgets and validate in update handlers, not during render.
ui.field({
label: "Email",
required: true,
error: state.touched.email ? state.errors.email : undefined,
children: ui.input({
id: "email",
value: state.email,
onInput: (value) =>
app.update((s) => {
const next = { ...s, email: value };
return { ...next, errors: validate(next) };
}),
onBlur: () => app.update((s) => ({ ...s, touched: { ...s.touched, email: true } })),
}),
});
6) Keybindings: global, modes, and chords¶
Use app.keys() for global bindings and chord sequences; use app.modes() for contextual maps.
app.keys({
"ctrl+s": () => save(),
"g g": () => jumpTop(),
"ctrl+x ctrl+s": () => save(),
});
app.modes({
normal: {
i: () => app.setMode("insert"),
"d d": () => deleteLine(),
},
insert: {
escape: () => app.setMode("normal"),
},
});
app.setMode("normal");
7) Overlays: modals and toasts¶
Render overlays in ui.layers(...); later children are on top.
ui.layers([
MainScreen(state),
state.showConfirm &&
ui.modal({
id: "confirm",
title: "Delete file?",
content: ui.text("This action cannot be undone."),
actions: [
ui.button({ id: "cancel", label: "Cancel" }),
ui.button({ id: "delete", label: "Delete" }),
],
onClose: () => app.update((s) => ({ ...s, showConfirm: false })),
}),
ui.toastContainer({
toasts: state.toasts,
position: "bottom-right",
onClose: (id) => app.update((s) => ({ ...s, toasts: s.toasts.filter((t) => t.id !== id) })),
}),
]);
8) Debugging and inspector¶
Use the debug controller for traces and the inspector overlay for live frame/focus inspection.
import { createAppWithInspectorOverlay, createDebugController } from "@rezi-ui/core";
import { createNodeBackend } from "@rezi-ui/node";
const backend = createNodeBackend();
const debug = createDebugController({
backend: backend.debug,
terminalCapsProvider: () => backend.getCaps(),
});
await debug.enable({ minSeverity: "info" });
const app = createAppWithInspectorOverlay({
backend,
initialState,
inspector: {
hotkey: "ctrl+shift+i",
debug,
},
});
9) Performance¶
Use Rezi's deterministic performance model directly:
- Keep
app.view(state)pure and cheap. - Keep
keystable in dynamic lists. - Precompute expensive derived data during
app.update(...). - Use
ui.virtualList/ui.tablefor large datasets. - Turn on debug
perf/framecategories to inspect hotspots.
Quick migration checklist¶
- Replace
render(...)entrypoint withcreateNodeApp+app.view+app.start. - Move React component local state to app state or
defineWidgetcontext hooks. - Replace
useInput-style handlers withapp.keys,app.modes, and widget callbacks. - Convert Yoga assumptions to cell-based layout (
row/column/box,flex, cell spacing). - Add stable
id(focus/routing) and stablekey(list reconciliation). - Verify behavior with debug traces and inspector overlay before parity signoff.