Skip to main content

Overview

defineWidget enables building reusable, stateful components with hooks. Each widget instance maintains its own state that persists across renders.
import { defineWidget, ui } from "@rezi-ui/core";

type CounterProps = {
  initial: number;
  key?: string; // Required for keying
};

const Counter = defineWidget<CounterProps>((props, ctx) => {
  const [count, setCount] = ctx.useState(props.initial);

  return ui.row([
    ui.text(`Count: ${count}`),
    ui.button({
      id: ctx.id("inc"),
      label: "+",
      onPress: () => setCount(c => c + 1),
    }),
  ]);
});

// Usage
ui.column([Counter({ initial: 0 }), Counter({ initial: 10, key: "c2" })]);

API

defineWidget

render
(props: Props, ctx: WidgetContext) => VNode
required
Render function receiving props and context.
options
DefineWidgetOptions
Optional configuration.
options.name
string
Display name for debugging.
options.wrapper
'column' | 'row'
Container wrapper kind (default: 'column').
Returns: Widget factory function

WidgetContext

The context provides these APIs:
ctx.id
(suffix: string) => string
Generate scoped ID unique to this widget instance.
ctx.useState
<T>(initial: T | (() => T)) => [T, (v: T | ((prev: T) => T)) => void]
Local state that persists across renders.
ctx.useRef
<T>(initial: T) => { current: T }
Mutable ref that doesn’t trigger re-renders.
ctx.useEffect
(effect: () => void | (() => void), deps?) => void
Side effects with cleanup.
ctx.useMemo
<T>(factory: () => T, deps?) => T
Memoized computed value.
ctx.useCallback
<T>(callback: T, deps?) => T
Memoized callback reference.
ctx.useAppState
<T>(selector: (s: S) => T) => T
Select app state slice.
ctx.useTheme
() => ColorTokens | null
Access semantic color tokens.
ctx.useViewport
() => ResponsiveViewportSnapshot
Current viewport dimensions.
ctx.invalidate
() => void
Request widget re-render.

Props Best Practices

Always Include key Prop

type MyWidgetProps = {
  data: string;
  key?: string; // Required for reconciliation
};

const MyWidget = defineWidget<MyWidgetProps>((props, ctx) => {
  return ui.text(props.data);
});

Use Readonly Props

type ReadonlyProps = Readonly<{
  count: number;
  items: readonly string[];
}>;

const Widget = defineWidget<ReadonlyProps>((props, ctx) => {
  // props is immutable
  return ui.text(`${props.count}`);
});

State Management

Local State

Use ctx.useState for widget-local state:
const TodoList = defineWidget((props, ctx) => {
  const [todos, setTodos] = ctx.useState<string[]>([]);
  const [input, setInput] = ctx.useState("");

  const addTodo = () => {
    if (input.trim()) {
      setTodos(prev => [...prev, input]);
      setInput("");
    }
  };

  return ui.column([
    ui.input({ id: ctx.id("input"), value: input, onChange: setInput }),
    ui.button({ id: ctx.id("add"), label: "Add", onPress: addTodo }),
    ...todos.map((todo, i) => ui.text(`${i + 1}. ${todo}`)),
  ]);
});

App State Access

type AppState = { user: { name: string }; theme: string };

const UserBadge = defineWidget<{}, AppState>((props, ctx) => {
  const userName = ctx.useAppState(s => s.user.name);
  const theme = ctx.useAppState(s => s.theme);

  return ui.badge({ label: userName, variant: theme === "dark" ? "solid" : "outline" });
});

Scoped IDs

Use ctx.id() for unique IDs:
const Form = defineWidget((props, ctx) => {
  const [name, setName] = ctx.useState("");
  const [email, setEmail] = ctx.useState("");

  return ui.column([
    ui.input({ id: ctx.id("name"), value: name, onChange: setName }),
    ui.input({ id: ctx.id("email"), value: email, onChange: setEmail }),
    ui.button({ id: ctx.id("submit"), label: "Submit" }),
  ]);
});

// Generated IDs: "Form_0_name", "Form_0_email", "Form_0_submit"
// Second instance: "Form_1_name", "Form_1_email", "Form_1_submit"

Effects and Lifecycle

const DataFetcher = defineWidget<{ url: string }>((props, ctx) => {
  const [data, setData] = ctx.useState(null);

  ctx.useEffect(() => {
    let cancelled = false;

    fetch(props.url)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) setData(data);
      });

    return () => { cancelled = true; }; // Cleanup
  }, [props.url]);

  return ui.text(data ? JSON.stringify(data) : "Loading...");
});

Animation

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

const FadeIn = defineWidget<{ visible: boolean }>((props, ctx) => {
  const opacity = useTransition(ctx, props.visible ? 1 : 0, {
    duration: 300,
  });

  return ui.box({ opacity }, [ui.text("Fading content")]);
});

Composition

const Panel = defineWidget<{ title: string; children: VNode[] }>((props, ctx) => {
  const [collapsed, setCollapsed] = ctx.useState(false);

  return ui.column([
    ui.row([
      ui.text(props.title, { variant: "heading" }),
      ui.button({
        id: ctx.id("toggle"),
        label: collapsed ? "Expand" : "Collapse",
        onPress: () => setCollapsed(!collapsed),
      }),
    ]),
    ...(!collapsed ? props.children : []),
  ]);
});

// Usage
Panel({ title: "Details", children: [ui.text("Content")] });

Testing

import { createTestRenderer, ui } from "@rezi-ui/core";
import { describe, test, assert } from "@rezi-ui/testkit";

const MyWidget = defineWidget<{ value: number }>((props, ctx) => {
  return ui.text(`Value: ${props.value}`);
});

describe("MyWidget", () => {
  test("renders value", () => {
    const renderer = createTestRenderer({ width: 80, height: 24 });
    const tree = MyWidget({ value: 42 });
    const result = renderer.render(tree);

    assert(result.text.includes("Value: 42"));
  });
});

Hook Rules

All hooks must be called in the same order every render. No conditional hook calls.
// Bad
if (props.enabled) {
  const [count, setCount] = ctx.useState(0); // Error!
}

// Good
const [count, setCount] = ctx.useState(0);
if (!props.enabled) return ui.empty();

State Hooks

useState, useRef, useMemo

Lifecycle

useEffect

Animation

useTransition, useSpring

Widget Catalog

Built-in widgets

Build docs developers (and LLMs) love