Skip to content

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:

  • useState and useRef for local state
  • useReducer for reducer-driven local state transitions
  • useEffect for post-commit effects with cleanup
  • useMemo and useCallback for memoization with React-compatible dependency semantics
  • useAppState to select a slice of app state
  • useTheme to read semantic design tokens (ColorTokens | null)
  • useViewport to read the current responsive viewport snapshot
  • id() to create scoped IDs for focusable widgets
  • invalidate() to request a re-render

Behavior details:

  • defineWidget(...) uses a layout-transparent fragment wrapper by default, so returning ui.row(...) or ui.column(...) preserves the child layout contract. Pass { wrapper: "row" } or { wrapper: "column" } only when you intentionally need an extra container node.
  • useEffect cleanup runs before an effect re-fires, and unmount cleanups run in reverse declaration order.
  • useMemo and useCallback compare dependencies with Object.is (including NaN equality and +0/-0 distinction).
  • useAppState uses selector snapshots and Object.is equality; 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)}`);
});