Nodes

A node is a plain object with a component name and props. JSX produces exactly this, so the two lines below are the same value:

import type { ComponentNode } from '@textui/core';
import { Row } from '@textui/core';

const fromJsx = <Row gap={1} />;
const fromData: ComponentNode = { component: 'Row', gap: 1 };

Nothing in a node is a module reference - 'Row' is a name the registry resolves at mount time, not an import. That is what lets a screen be persisted, generated or sent, and it is why the component pages document both forms.

Reserved keys

Four keys are structural. Everything else on the object is props, and props are the component’s business.

Key Means
component The registered name to mount. The only required key.
id A stable identity for this node.
key Reconciliation identity among siblings.
$meta Internal - origin, error fallback, source location. Not yours to set.

Children

Inline, as an array:

import type { ComponentNode } from '@textui/core';

const row: ComponentNode = {
  component: 'Row',
  gap: 1,
  children: [
    { component: 'text', content: 'left' },
    { component: 'spacer', flex: 1 },
    { component: 'text', content: 'right' },
  ],
};

Or as one instance of a template per item at a store path, which is how a list of unknown length is expressed without a loop:

import type { ComponentNode } from '@textui/core';

const list: ComponentNode = {
  component: 'Column',
  children: {
    template: { component: 'text', content: { path: '/name' } },
    path: '$/services/list',
  },
};

Inside a template, a relative path like /name is read against the current item - each expansion is given a data context of $/services/list/0, /1 and so on. $/ escapes back to the root of the store.

Reconciliation keys come from the item, not the template: an item with an id is keyed by it, and one without is keyed by its index. Giving rows a stable id is what stops a reorder from being read as an edit to every row.

Props that are not values

A prop can hold data describing where its value comes from, or what should happen, instead of the value itself. Three shapes, distinguished by their keys.

A binding reads the store

import type { ComponentNode } from '@textui/core';

const title: ComponentNode = {
  component: 'Heading',
  content: { path: '$/session/title' },
};

The runtime reads the path, subscribes to exactly it, and re-renders this node when it changes. Nothing else re-renders, and the component needs no support for any of it. See Paths and scopes.

A function call returns a value

import type { ComponentNode } from '@textui/core';

const count: ComponentNode = {
  component: 'Badge',
  label: { call: 'services.count' },
};

An action describes what to run

Three forms, on any prop named on followed by a capital - onPress, onSelect, onChange, and so on. once and only are not handler props; the capital is what the runtime tests for.

import type { ComponentNode } from '@textui/core';

const buttons: ComponentNode[] = [
  { component: 'Button', label: 'Local',   onPress: { handler: () => save() } },
  { component: 'Button', label: 'Command', onPress: { functionCall: { call: 'file.save' } } },
  { component: 'Button', label: 'Event',   onPress: { emit: { path: '@/dialog/cancel' } } },
];
Form Runs Stays data
{ handler } The closure No
{ functionCall } A registered command Yes
{ emit } Publishes on an event path Yes

A bare function works too - onPress: save - because the resolver passes functions through untouched. The library wraps closures in { handler } when it builds nodes as objects, to keep the three forms symmetric.

The runtime turns whichever you wrote into a callable before the component sees it, so a component receives a function in every case and never inspects which form was used. This is keyed on the prop’s name, not on a list of known props, so it works on components you write without you doing anything.

What survives being turned into JSON

{ functionCall } and { emit } name what to run rather than holding it, so they are ordinary JSON. A closure is not, and JSON.stringify drops it - which is the whole of the difference between the two, and the reason to prefer a command for anything a user can also reach from a keybinding or the palette.

See also


Back to top

MIT licensed. Pre-1.0 - the surface is still moving.