$ rezi
Guides

Hooks Reference

Hooks are functions available on the WidgetContext (ctx) inside defineWidget render functions. They let composite widgets manage local state, run side effects, and access app-level data without bre...

Hooks are functions available on the WidgetContext (ctx) inside defineWidget render functions. They let composite widgets manage local state, run side effects, and access app-level data without breaking the declarative VNode model.

import { defineWidget, ui } from "@rezi-ui/core";

const MyWidget = defineWidget<{ key?: string }>((props, ctx) => {
  const [count, setCount] = ctx.useState(0);
  // ... use hooks here ...
  return ui.text(`Count: ${count}`);
});

Core Hooks

ctx.useState

Create local state that persists across renders. Returns a [value, setter] tuple.

Signature:

ctx.useState<T>(initial: T | (() => T)): [T, (v: T | ((prev: T) => T)) => void]

Description:

  • The initial argument is used only on the first render. Pass a function for lazy initialization (useful when the initial value is expensive to compute).
  • The setter accepts either a new value or an updater function that receives the previous value.
  • Calling the setter schedules a re-render of the widget.

Example:

const MyWidget = defineWidget<{ key?: string }>((props, ctx) => {
  const [count, setCount] = ctx.useState(0);
  const [items, setItems] = ctx.useState<string[]>(() => []);

  return ui.column({ gap: 1 }, [
    ui.text(`Count: ${count}`),
    ui.button({
      id: ctx.id("inc"),
      label: "Increment",
      onPress: () => setCount((prev) => prev + 1),
    }),
    ui.button({
      id: ctx.id("add"),
      label: "Add Item",
      onPress: () => setItems((prev) => [...prev, `Item ${prev.length + 1}`]),
    }),
  ]);
});

Rules:

  • Must be called in the same order every render (no conditional calls).
  • The setter is stable across renders -- you do not need to memoize it.

ctx.useRef

Create a mutable ref that persists across renders without triggering re-renders.

Signature:

ctx.useRef<T>(initial: T): { current: T }

Description:

  • Returns an object with a mutable current property.
  • Changing current does not cause a re-render.
  • Useful for storing values that need to survive across renders but should not trigger UI updates (timers, DOM references, mutable counters).

Example:

const Timer = defineWidget<{ key?: string }>((props, ctx) => {
  const [elapsed, setElapsed] = ctx.useState(0);
  const intervalRef = ctx.useRef<ReturnType<typeof setInterval> | null>(null);

  ctx.useEffect(() => {
    intervalRef.current = setInterval(() => {
      setElapsed((e) => e + 1);
    }, 1000);
    return () => {
      if (intervalRef.current !== null) {
        clearInterval(intervalRef.current);
      }
    };
  }, []);

  return ui.text(`Elapsed: ${elapsed}s`);
});

ctx.useEffect

Register a side effect to run after the commit phase. Similar to React's useEffect.

Signature:

ctx.useEffect(effect: () => void | (() => void), deps?: readonly unknown[]): void

Description:

  • The effect function runs after the widget's VNode tree is committed.
  • If effect returns a cleanup function, that cleanup runs before the next effect execution and when the widget unmounts.
  • The deps array controls when the effect re-runs:
    • Omitted: runs after every render.
    • Empty array []: runs once on mount, cleanup on unmount.
    • With values: re-runs when any dependency changes (compared with Object.is).

Example:

const SearchBox = defineWidget<{ query: string; key?: string }>((props, ctx) => {
  const [results, setResults] = ctx.useState<string[]>([]);

  ctx.useEffect(() => {
    if (props.query.length === 0) {
      setResults([]);
      return;
    }

    let cancelled = false;
    fetchResults(props.query).then((data) => {
      if (!cancelled) setResults(data);
    });

    return () => { cancelled = true; };
  }, [props.query]);

  return ui.column({ gap: 1 }, [
    ui.text(`Results for "${props.query}":`),
    ...results.map((r, i) => ui.text(r, { key: String(i) })),
  ]);
});

ctx.useMemo

Memoize a computed value until dependencies change.

Signature:

ctx.useMemo<T>(factory: () => T, deps?: readonly unknown[]): T

Description:

  • Calls factory on the first render and caches the result.
  • On subsequent renders, returns the cached result unless one of the deps has changed (compared with Object.is).
  • Use for expensive computations that depend on specific inputs.

Example:

const FilteredList = defineWidget<{ items: Item[]; filter: string; key?: string }>(
  (props, ctx) => {
    const filtered = ctx.useMemo(
      () => props.items.filter((item) =>
        item.name.toLowerCase().includes(props.filter.toLowerCase())
      ),
      [props.items, props.filter],
    );

    return ui.column({ gap: 0 },
      filtered.map((item) => ui.text(item.name, { key: item.id })),
    );
  },
);

ctx.useCallback

Memoize a callback reference until dependencies change.

Signature:

ctx.useCallback<T extends (...args: never[]) => unknown>(
  callback: T,
  deps?: readonly unknown[],
): T

Description:

  • Returns a stable function reference that only changes when a dependency changes.
  • Useful when passing callbacks to child widgets or hooks that compare by reference.

Example:

const Editor = defineWidget<{ key?: string }>((props, ctx) => {
  const [text, setText] = ctx.useState("");

  const handleInput = ctx.useCallback(
    (value: string) => setText(value),
    [],
  );

  return ui.input({
    id: ctx.id("editor"),
    value: text,
    onInput: handleInput,
  });
});

App Hooks

ctx.useAppState

Select a slice of the application-level state. Available when the widget has access to app state (the State type parameter of WidgetContext<State>).

Signature:

ctx.useAppState<T>(selector: (state: State) => T): T

Description:

  • Runs selector against the current app state and returns the result.
  • The widget re-renders when the selected slice changes.

Example:

const UserBadge = defineWidget<{ key?: string }, AppState>((props, ctx) => {
  const userName = ctx.useAppState((s) => s.user.name);
  const isOnline = ctx.useAppState((s) => s.user.online);

  return ui.row({ gap: 1 }, [
    ui.status(isOnline ? "online" : "offline"),
    ui.text(userName),
  ]);
});

ctx.useViewport

Read the widget's current viewport snapshot.

Signature:

ctx.useViewport(): ResponsiveViewportSnapshot

Description:

  • Returns the current renderer viewport snapshot for responsive/layout-aware rendering.
  • The runtime triggers viewport usage tracking when available and falls back to the default viewport snapshot.

Utility Hooks

These are standalone functions (not on ctx) that accept a context argument. They compose core hooks internally.

useDebounce

Return a debounced copy of a value that updates only after a delay.

Signature:

import { useDebounce } from "@rezi-ui/core";

useDebounce<T>(ctx: WidgetContext, value: T, delayMs: number): T

Description:

  • The returned value updates only after delayMs milliseconds have elapsed without a new input value.
  • Non-positive or non-finite delays apply the value on the next effect pass (effectively no delay).
  • Internally uses ctx.useState and ctx.useEffect.

Example:

import { defineWidget, useDebounce, ui } from "@rezi-ui/core";

const SearchInput = defineWidget<{ key?: string }>((props, ctx) => {
  const [query, setQuery] = ctx.useState("");
  const debouncedQuery = useDebounce(ctx, query, 300);

  ctx.useEffect(() => {
    if (debouncedQuery.length > 0) {
      performSearch(debouncedQuery);
    }
  }, [debouncedQuery]);

  return ui.input({
    id: ctx.id("search"),
    value: query,
    onInput: (v) => setQuery(v),
  });
});

usePrevious

Track the previous render's value.

Signature:

import { usePrevious } from "@rezi-ui/core";

usePrevious<T>(ctx: WidgetContext, value: T): T | undefined

Description:

  • Returns undefined on the first render.
  • On subsequent renders, returns the value from the previous render cycle.
  • Internally uses ctx.useRef and ctx.useEffect.

Example:

import { defineWidget, usePrevious, ui } from "@rezi-ui/core";

const CounterWithDelta = defineWidget<{ count: number; key?: string }>(
  (props, ctx) => {
    const prevCount = usePrevious(ctx, props.count);
    const delta = prevCount !== undefined ? props.count - prevCount : 0;

    return ui.row({ gap: 1 }, [
      ui.text(`Count: ${props.count}`),
      delta !== 0 && ui.text(`(${delta > 0 ? "+" : ""}${delta})`, { dim: true }),
    ]);
  },
);

useAsync

Run an async operation when dependencies change. Manages loading/data/error state automatically.

Signature:

import { useAsync, type UseAsyncState } from "@rezi-ui/core";

useAsync<T>(
  ctx: WidgetContext,
  task: () => Promise<T>,
  deps: readonly unknown[],
): UseAsyncState<T>

type UseAsyncState<T> = Readonly<{
  data: T | undefined;
  loading: boolean;
  error: unknown;
}>;

Description:

  • Sets loading to true while the operation is in-flight.
  • Stores the resolved value in data.
  • Stores any thrown/rejected value in error.
  • Ignores stale completions from older dependency runs (race condition safe).
  • Internally uses ctx.useState, ctx.useRef, and ctx.useEffect.

Example:

import { defineWidget, useAsync, ui } from "@rezi-ui/core";

const UserProfile = defineWidget<{ userId: string; key?: string }>(
  (props, ctx) => {
    const { data: user, loading, error } = useAsync(
      ctx,
      () => fetchUser(props.userId),
      [props.userId],
    );

    if (loading) return ui.spinner({ label: "Loading profile..." });
    if (error) return ui.errorDisplay("Failed to load profile");
    if (!user) return ui.empty("No user found");

    return ui.column({ gap: 1 }, [
      ui.text(user.name, { style: { bold: true } }),
      ui.text(user.email, { dim: true }),
    ]);
  },
);

Widget Hooks

Higher-level hooks that manage complex widget state patterns. These are standalone functions that accept a WidgetContext as their first argument.

useTable

Convenience hook that wires up sorting, selection, and row-key management for ui.table. Returns a props object that can be spread directly into ui.table(...).

Signature:

import { useTable, type UseTableOptions, type UseTableResult } from "@rezi-ui/core";

useTable<T, State = void>(
  ctx: WidgetContext<State>,
  options: UseTableOptions<T>,
): UseTableResult<T>

Key options:

type UseTableOptions<T> = {
  id?: string;                                   // Table widget ID (auto-generated if omitted)
  rows: readonly T[];                            // Data rows
  columns: readonly TableColumn<T>[];            // Column definitions
  getRowKey?: (row: T, index: number) => string; // Row identity (defaults to row.id or index)
  selectable?: TableSelectionMode;               // "none" | "single" | "multi" (default: "none")
  sortable?: boolean;                            // Enable sorting on all columns (default: false)
  defaultSelection?: readonly string[];          // Initial selection
  defaultSortColumn?: string;                    // Initial sort column key
  defaultSortDirection?: SortDirection;           // "asc" | "desc" (default: "asc")
  onSelectionChange?: (keys: readonly string[]) => void;
  onSortChange?: (column: string, direction: SortDirection) => void;
  // ... plus any other TableProps (passed through)
};

Return value:

type UseTableResult<T> = {
  props: TableProps<T>;                          // Spread into ui.table(...)
  rows: readonly T[];                            // Sorted rows (for external use)
  selection: readonly string[];                  // Current selection keys
  sortColumn: string | undefined;
  sortDirection: SortDirection | undefined;
  clearSelection: () => void;
  setSort: (column: string, direction: SortDirection) => void;
};

Example:

import { defineWidget, useTable, ui } from "@rezi-ui/core";

type FileRow = { id: string; name: string; size: number };

const FileTable = defineWidget<{ files: FileRow[]; key?: string }>(
  (props, ctx) => {
    const table = useTable(ctx, {
      rows: props.files,
      columns: [
        { key: "name", header: "Name", flex: 1 },
        { key: "size", header: "Size", width: 10, align: "right" },
      ],
      selectable: "multi",
      sortable: true,
    });

    return ui.column({ gap: 1 }, [
      ui.text(`${table.selection.length} selected`),
      ui.table(table.props),
    ]);
  },
);

useModalStack

Manage a LIFO stack of modals with automatic focus restoration between layers.

Signature:

import { useModalStack, type UseModalStack } from "@rezi-ui/core";

useModalStack<State = void>(ctx: WidgetContext<State>): UseModalStack

Return value:

type UseModalStack = {
  push: (id: string, props: Omit<ModalProps, "id">) => void;   // Push a new modal
  pop: () => void;                                               // Remove top modal
  clear: () => void;                                             // Remove all modals
  current: () => string | null;                                  // ID of top modal
  size: number;                                                  // Number of stacked modals
  render: () => readonly VNode[];                                // Render all modals
};

Description:

  • Modals are stacked in LIFO order; only the top modal captures focus.
  • When a modal is popped, focus returns to the first action button of the modal beneath it (or the element specified by returnFocusTo).
  • Each modal's onClose is automatically wired to pop() (or remove-by-id for non-top modals).
  • Keys are auto-versioned so initialFocus re-applies when a covered modal is revealed.

Example:

import { defineWidget, useModalStack, ui } from "@rezi-ui/core";

const App = defineWidget<{ key?: string }>((props, ctx) => {
  const modals = useModalStack(ctx);

  const openConfirm = () => {
    modals.push("confirm", {
      title: "Confirm Action",
      content: ui.text("Are you sure?"),
      actions: [
        ui.button({ id: "yes", label: "Yes", onPress: () => {
          modals.pop();
          performAction();
        }}),
        ui.button({ id: "no", label: "No", onPress: () => modals.pop() }),
      ],
    });
  };

  return ui.layers([
    ui.column({ gap: 1 }, [
      ui.button({ id: "open", label: "Open Dialog", onPress: openConfirm }),
    ]),
    ...modals.render(),
  ]);
});

useForm

Full-featured form management hook with validation, touched/dirty tracking, submission, array fields, disabled/read-only state, and multi-step wizard support.

Signature:

import { useForm, type UseFormOptions, type UseFormReturn } from "@rezi-ui/core";

useForm<T extends Record<string, unknown>, State = void>(
  ctx: WidgetContext<State>,
  options: UseFormOptions<T>,
): UseFormReturn<T>

Key options:

type UseFormOptions<T> = {
  initialValues: T;                                         // Initial field values
  validate?: (values: T) => Partial<Record<keyof T, string>>; // Sync validation
  validateAsync?: (values: T) => Promise<ValidationResult<T>>; // Async validation
  validateAsyncDebounce?: number;                           // Async debounce (default: 300ms)
  validateOnChange?: boolean;                               // Validate on every change (default: false)
  validateOnBlur?: boolean;                                 // Validate on blur (default: true)
  onSubmit: (values: T) => void | Promise<void>;           // Submit handler
  resetOnSubmit?: boolean;                                  // Reset after submit (default: false)
  disabled?: boolean;                                       // Form-level disabled
  readOnly?: boolean;                                       // Form-level read-only
  fieldDisabled?: Partial<Record<keyof T, boolean>>;        // Per-field disabled overrides
  fieldReadOnly?: Partial<Record<keyof T, boolean>>;        // Per-field read-only overrides
  wizard?: { steps: FormWizardStep<T>[]; initialStep?: number }; // Multi-step wizard
};

Key return properties:

PropertyTypeDescription
valuesTCurrent form field values
errorsPartial<Record<keyof T, FieldErrorValue>>Validation errors by field
touchedPartial<Record<keyof T, FieldBooleanValue>>Which fields have been blurred
dirtyPartial<Record<keyof T, FieldBooleanValue>>Which fields differ from initial
isValidbooleanTrue if no validation errors
isDirtybooleanTrue if any field modified
isSubmittingbooleanTrue during async submission
bind(field)UseFormInputBindingSpread-ready props for ui.input(...)
field(field, opts?)VNodeFully wired ui.field(...) with child ui.input(...)
handleChange(field)(value) => voidChange handler factory
handleBlur(field)() => voidBlur handler factory
handleSubmit() => voidSubmit (validates then calls onSubmit)
reset() => voidReset to initial values
setFieldValue(field, value) => voidProgrammatic field update
setFieldError(field, error) => voidProgrammatic error
validateField(field) => errorValidate single field
validateForm() => errorsValidate all fields
useFieldArray(field)UseFieldArrayReturnDynamic array field helpers
nextStep() => booleanWizard: advance (validates current step)
previousStep() => voidWizard: go back (no validation)
goToStep(stepIndex)(stepIndex: number) => booleanWizard: jump to step (validates forward)

Example:

import { defineWidget, useForm, ui } from "@rezi-ui/core";

type LoginForm = { username: string; password: string };

const LoginWidget = defineWidget<{ key?: string }>((props, ctx) => {
  const form = useForm<LoginForm>(ctx, {
    initialValues: { username: "", password: "" },
    validate: (values) => {
      const errors: Partial<Record<keyof LoginForm, string>> = {};
      if (values.username.length === 0) errors.username = "Required";
      if (values.password.length < 8) errors.password = "Min 8 characters";
      return errors;
    },
    onSubmit: async (values) => {
      await login(values.username, values.password);
    },
  });

  return ui.form([
    // form.field() auto-wires label, error display, and input binding
    form.field("username", { label: "Username", required: true }),
    form.field("password", { label: "Password", required: true }),

    ui.actions([
      ui.button({
        id: ctx.id("submit"),
        label: form.isSubmitting ? "Submitting..." : "Log In",
        onPress: form.handleSubmit,
        disabled: form.isSubmitting,
      }),
    ]),
  ]);
});

Array fields example:

const TagEditor = defineWidget<{ key?: string }>((props, ctx) => {
  const form = useForm<{ tags: string[] }>(ctx, {
    initialValues: { tags: ["default"] },
    onSubmit: (values) => saveTags(values.tags),
  });

  const tags = form.useFieldArray("tags");

  return ui.column({ gap: 1 }, [
    ...tags.values.map((tag, i) =>
      ui.row({ key: tags.keys[i], gap: 1 }, [
        ui.text(tag),
        ui.button({
          id: ctx.id(`remove-${String(i)}`),
          label: "X",
          onPress: () => tags.remove(i),
        }),
      ]),
    ),
    ui.button({
      id: ctx.id("add"),
      label: "Add Tag",
      onPress: () => tags.append("new-tag"),
    }),
  ]);
});

Rules of Hooks

Hooks have ordering requirements that must be followed for correct behavior:

1. Call hooks in the same order every render

Hooks are tracked by call order, not by name. You must never conditionally call a hook -- the sequence of hook calls must be identical on every render of a given widget instance.

// WRONG -- conditional hook call
const Widget = defineWidget<{ showExtra: boolean; key?: string }>((props, ctx) => {
  const [count, setCount] = ctx.useState(0);
  if (props.showExtra) {
    const [extra, setExtra] = ctx.useState("");  // Hook order changes!
  }
  return ui.text(`${count}`);
});

// CORRECT -- always call, conditionally use
const Widget = defineWidget<{ showExtra: boolean; key?: string }>((props, ctx) => {
  const [count, setCount] = ctx.useState(0);
  const [extra, setExtra] = ctx.useState("");    // Always called
  return ui.column({}, [
    ui.text(`${count}`),
    props.showExtra && ui.text(extra),            // Conditionally render
  ]);
});

2. Only call hooks inside defineWidget render functions

Hooks rely on the WidgetContext (ctx) which is only available inside the render function passed to defineWidget. Do not call hooks outside of this context.

// WRONG -- hooks outside defineWidget
function badHelper() {
  const [x, setX] = ctx.useState(0);  // ctx is not available here
}

// CORRECT -- pass ctx explicitly for utility hooks
function goodHelper(ctx: WidgetContext) {
  return useDebounce(ctx, someValue, 300);
}

3. Never call hooks in loops with variable iteration counts

If the loop count can change between renders, the hook call count changes too.

// WRONG -- variable loop count
items.forEach((item) => {
  const [selected, setSelected] = ctx.useState(false);
});

// CORRECT -- use a single state for the collection
const [selected, setSelected] = ctx.useState<Set<string>>(new Set());

4. Utility hooks consume multiple hook slots

Functions like useDebounce, usePrevious, useAsync, useTable, useModalStack, and useForm internally call multiple core hooks. Their position in the call sequence matters just like any other hook.

5. Effect cleanup runs before re-execution

When an effect's dependencies change, the cleanup function from the previous execution runs before the new effect runs. On unmount, all effect cleanups run. Always return cleanup functions for resources like timers, subscriptions, or abort controllers.

ctx.useEffect(() => {
  const controller = new AbortController();
  fetch(url, { signal: controller.signal }).then(handleResponse);
  return () => controller.abort();  // Cleanup on deps change or unmount
}, [url]);

6. ctx.id() for scoped widget IDs

Always use ctx.id(suffix) to generate interactive widget IDs inside defineWidget. This ensures each widget instance gets unique IDs that do not collide with other instances of the same widget.

// Generates IDs like "Counter_0_inc", "Counter_1_inc" for different instances
ui.button({ id: ctx.id("inc"), label: "+" })

On this page