A2UI · Guides

Working with the data model

A surface's data lives in a plain object, and you read and write it through three pointer helpers plus a resolver. This guide covers all four.

The data model is just a Record<string, unknown>. A2UI components reference into it by JSON-Pointer-style path; @threadplane/a2ui gives you the helpers to read, write, and resolve against it.

The pointer helpers

getByPointer, setByPointer, and deleteByPointer all take a model and a pointer string (/user/name, /items/1).

getByPointer walks the path and returns the value, or undefined if any segment is missing:

import { getByPointer } from '@threadplane/a2ui';
 
getByPointer({ items: ['a', 'b', 'c'] }, '/items/1'); // "b"

setByPointer writes immutably. It returns a clone with the change applied; the original is untouched.

import { setByPointer } from '@threadplane/a2ui';
 
const original = { user: { name: 'Alice', age: 30 } };
const next = setByPointer(original, '/user/name', 'Bob');
 
next.user.name;     // "Bob"
original.user.name; // "Alice" — unchanged

It also creates intermediate objects along the way, so you don't have to pre-build nesting:

setByPointer({}, '/a/b/c', 42); // { a: { b: { c: 42 } } }

deleteByPointer removes a key, again immutably:

import { deleteByPointer } from '@threadplane/a2ui';
 
deleteByPointer({ a: 1, b: 2 }, '/a'); // { b: 2 }

If the parent of the target doesn't exist, deleteByPointer returns the original model unchanged rather than fabricating a path to delete from.

One v0.9-specific rule: deleting an array index does not splice. The index is set to undefined and the array's length is preserved, so sibling indices stay stable for other bindings:

deleteByPointer({ items: ['a', 'b', 'c'] }, '/items/1');
// { items: ['a', undefined, 'c'] } — length still 3
No RFC-6901 escaping

These helpers use JSON-Pointer-style syntax but do not implement RFC 6901's ~0 / ~1 unescaping. A path is split on / and the segments are used as literal keys. So keys that themselves contain / or ~ aren't addressable — there's no escape sequence to reach them.

Applying updateDataModel envelopes

The pointer helpers are the primitives. The most common real task is one level up: applying an updateDataModel envelope to the model. In v0.9 the envelope's value is plain JSON — no typed entry wrappers — so applying it is a direct mapping onto the helpers:

  • value present, path present — setByPointer(model, path, value) replaces (or creates) the data at path.
  • value present, path omitted or '/' — the whole model is replaced by value.
  • value omitted — the key at path is deleted (deleteByPointer); with no path, the model resets to {}.
import { setByPointer, deleteByPointer } from '@threadplane/a2ui';
import type { A2uiUpdateDataModel } from '@threadplane/a2ui';
 
function applyUpdateDataModel(
  model: Record<string, unknown>,
  update: A2uiUpdateDataModel,
): Record<string, unknown> {
  const path = update.path && update.path !== '/' ? update.path : undefined;
  if (!('value' in update) || update.value === undefined) {
    return path ? deleteByPointer(model, path) : {};
  }
  if (!path) return update.value as Record<string, unknown>;
  return setByPointer(model, path, update.value);
}
 
applyUpdateDataModel(
  {},
  { surfaceId: 's1', path: '/customer', value: { name: 'Ada' } },
);
// { customer: { name: 'Ada' } }

Nesting is just JSON: value: { name: 'Ada', address: { city: 'London' } } writes the nested object as-is. There is no per-entry typing to reduce — the wire value is already the model shape.

Resolving dynamic values

resolveDynamic collapses a component's prop to a concrete value against the model. The order is fixed:

  1. null / undefined pass through as-is.
  2. Arrays are mapped recursively — each element resolved in turn.
  3. A { call } function-call value executes through the function registry passed to resolveDynamic (standard set: formatString, formatNumber, formatCurrency, formatDate, pluralize, and, or, not); args resolve recursively, so they may be bindings or nested calls. Without a registry, or for unknown names, the value resolves to undefined. Checked before path refs so a call's args never masquerade as a binding.
  4. A { path } reference reads from the model.
  5. Anything else — a bare string, number, boolean, or plain object — passes through unchanged. Bare values are the v0.9 literal form; there are no wrapper objects.
import { resolveDynamic } from '@threadplane/a2ui';
 
const model = { name: 'Brian', count: 7, active: true, tags: ['a', 'b'] };
 
resolveDynamic('hello', model);              // "hello"
resolveDynamic({ path: '/name' }, model);    // "Brian"
resolveDynamic({ path: '/tags/0' }, model);  // "a"
resolveDynamic({ path: '/missing' }, model); // undefined

A missing path resolves to undefined, never an error. That keeps a half-streamed surface renderable while data is still arriving.

Scopes and template children

How do you resolve a relative path, like inside a repeated template row?

resolveDynamic takes an optional third argument, an A2uiScope:

export interface A2uiScope {
  basePath: string;
  item: unknown;
}

Path resolution depends on the leading slash:

  • An absolute path (/name) always resolves from the model root, scope or not.
  • A relative path (name) resolves against scope.basePath when a scope is given.
resolveDynamic({ path: 'name' }, model, { basePath: '', item: undefined }); // "Brian"

With basePath: '', the relative path name resolves to /name. That's the lever children templates pull. When a container's children is { "path": "/items", "componentId": "tpl" }, it repeats the template component over the array at /items and resolves each instance's props with a per-item scope:

items.forEach((_, i) => {
  const scope = { basePath: `/items/${i}`, item: items[i] };
  // a child prop of { path: 'label' } now resolves to /items/{i}/label
  resolveDynamic({ path: 'label' }, model, scope);
});
scope.item is informational

A2uiScope carries an item field, but the resolver only reads basePath to rewrite relative paths. item is typed for callers that want the bound element on hand, yet resolveDynamic itself never touches it. Don't expect setting item to change resolution.

Next