Composition¶
Rezi supports reusable, stateful widgets via defineWidget. Composite widgets integrate with the runtime's reconciliation and update pipeline while keeping your view pure.
Defining a widget¶
import { defineWidget, ui } from "@rezi-ui/core";
type CounterProps = { label: string };
export const Counter = defineWidget<CounterProps>((props, ctx) => {
const [count, setCount] = ctx.useState(0);
return ui.card(`${props.label}`, [
ui.row({ gap: 1, items: "center" }, [
ui.text(`Count: ${count}`, { variant: "heading" }),
ui.spacer({ flex: 1 }),
ui.button({
id: ctx.id("inc"),
label: "+1",
intent: "primary",
onPress: () => setCount((c) => c + 1),
}),
]),
]);
});
WidgetContext hooks¶
Composite widgets receive a WidgetContext with:
useStateanduseReffor local stateuseReducerfor reducer-driven local state transitionsuseEffectfor post-commit effects with cleanupuseMemoanduseCallbackfor memoization with React-compatible dependency semanticsuseAppStateto select a slice of app stateuseThemeto read semantic design tokens (ColorTokens | null)useViewportto read the current responsive viewport snapshotid()to create scoped IDs for focusable widgetsinvalidate()to request a re-render
Behavior details:
defineWidget(...)uses a layout-transparent fragment wrapper by default, so returningui.row(...)orui.column(...)preserves the child layout contract. Pass{ wrapper: "row" }or{ wrapper: "column" }only when you intentionally need an extra container node.useEffectcleanup runs before an effect re-fires, and unmount cleanups run in reverse declaration order.useMemoanduseCallbackcompare dependencies withObject.is(includingNaNequality and+0/-0distinction).useAppStateuses selector snapshots andObject.isequality; widgets only re-render when selected values change.- Hook rules follow React constraints: keep both hook order and hook count consistent on every render.
Example: memoized table data¶
const filteredRows = ctx.useMemo(
() => rows.filter((row) => row.name.includes(query)),
[rows, query],
);
const onSubmit = ctx.useCallback(() => save(filteredRows), [save, filteredRows]);
Example: theme-aware custom widget¶
import { defineWidget, recipe, ui } from "@rezi-ui/core";
const ThemedSummary = defineWidget<{ title: string; value: string; key?: string }>((props, ctx) => {
const tokens = ctx.useTheme();
const titleStyle = tokens ? recipe.text(tokens, { role: "caption" }) : { dim: true };
const valueStyle = tokens ? recipe.text(tokens, { role: "title" }) : { bold: true };
return ui.box({ border: "rounded", p: 1 }, [
ui.text(props.title, { style: titleStyle }),
ui.text(props.value, { style: valueStyle }),
]);
});
Utility hooks¶
Rezi ships a small utility-hook layer for common composition patterns:
- Animation hooks for numeric motion and sequencing:
useTransition(ctx, value, config?)interpolates toward a numeric target over duration/easing.useSpring(ctx, target, config?)animates toward a target with spring physics.useSequence(ctx, keyframes, config?)runs keyframe timelines (optional loop).useStagger(ctx, items, config?)returns per-item eased progress for staggered entrances.useAnimatedValue(ctx, target, config?)exposes{ value, velocity, isAnimating }for transition/spring motion.useParallel(ctx, animations)runs multiple transitions concurrently.useChain(ctx, steps)runs transitions step-by-step.useDebounce(ctx, value, delayMs)returns a debounced value.useAsync(ctx, task, deps)runs async tasks with loading/error state and stale-result protection.usePrevious(ctx, value)returns the previous render value.useStream(ctx, asyncIterable, deps?)subscribes to async iterables and re-renders on each value.useEventSource(ctx, url, options?)consumes SSE feeds with automatic reconnect.useWebSocket(ctx, url, protocol?, options?)consumes websocket feeds with message parsing.useInterval(ctx, fn, ms)runs cleanup-safe intervals with latest-callback semantics.useTail(ctx, filePath, options?)tails file sources with bounded in-memory backpressure.
import {
defineWidget,
ui,
useAsync,
useDebounce,
useSequence,
useSpring,
useStagger,
useTransition,
usePrevious,
useStream,
} from "@rezi-ui/core";
type SearchProps = { query: string };
const SearchResults = defineWidget<SearchProps>((props, ctx) => {
const debouncedQuery = useDebounce(ctx, props.query, 250);
const prevQuery = usePrevious(ctx, debouncedQuery);
const { data, loading, error } = useAsync(
ctx,
() => fetchResults(debouncedQuery),
[debouncedQuery],
);
if (loading) return ui.text("Loading...");
if (error) return ui.text("Request failed");
return ui.column([
ui.text(`Previous query: ${prevQuery ?? "(none)"}`),
ui.text(`Results: ${String(data?.length ?? 0)}`),
]);
});
const AnimatedValue = defineWidget<{ value: number; key?: string }>((props, ctx) => {
const eased = useTransition(ctx, props.value, { duration: 160, easing: "easeOutCubic" });
const spring = useSpring(ctx, props.value, { stiffness: 180, damping: 22 });
const pulse = useSequence(ctx, [0.2, 1, 0.4, 1], { duration: 120, loop: true });
const stagger = useStagger(ctx, [0, 1, 2], { delay: 30, duration: 140 });
return ui.text(
`eased=${eased.toFixed(1)} spring=${spring.toFixed(1)} pulse=${pulse.toFixed(2)} stagger0=${(stagger[0] ?? 0).toFixed(2)}`,
);
});
async function fetchResults(query: string): Promise<string[]> {
return query.length > 0 ? [query] : [];
}
async function* fetchMetrics(): AsyncGenerator<number> {
while (true) {
await new Promise<void>((resolve) => setTimeout(resolve, 1000));
yield Math.round(Math.random() * 100);
}
}
const LiveMetric = defineWidget<{ key?: string }>((props, ctx) => {
const metricsStream = ctx.useMemo(() => fetchMetrics(), []);
const metric = useStream(ctx, metricsStream, [metricsStream]);
return ui.text(`Live metric: ${String(metric.value ?? 0)}`);
});