Page actions

signalStateStore()

Creates a reactive state store backed by Angular Signals that implements the StateStore interface from @json-render/core.

Import

import { signalStateStore } from '@threadplane/render';

Signature

function signalStateStore(initialState: StateModel = {}): SignalStateStore;

Parameters

ParameterTypeDefaultDescription
initialStateStateModel{}Initial state object. StateModel is Record<string, unknown>.

Returns

A SignalStateStore -- the StateStore interface from @json-render/core plus one extra member:

interface StateStore {
  get: (path: string) => unknown;
  set: (path: string, value: unknown) => void;
  update: (updates: Record<string, unknown>) => void;
  getSnapshot: () => StateModel;
  getServerSnapshot?: () => StateModel;
  subscribe: (listener: () => void) => () => void;
}
 
interface SignalStateStore extends StateStore {
  lastChange?: () => StateChangeRecord | undefined;
}
 
interface StateChangeRecord {
  readonly path: string;
  readonly value: unknown;
}

getServerSnapshot is an optional member of the @json-render/core interface used for server-side rendering. signalStateStore() does not implement it, so consumers fall back to getSnapshot().

SignalStateStore and StateChangeRecord are both exported from @threadplane/render. Anywhere a plain StateStore is accepted -- the store input on <render-spec>, the store field of provideRender() -- a SignalStateStore is accepted too.

Methods

get(path)

Reads a value from the store at the given JSON Pointer path.

const store = signalStateStore({ user: { name: 'Alice' }, items: ['a', 'b'] });
 
store.get('/user/name');  // 'Alice'
store.get('/user');       // { name: 'Alice' }
store.get('/items/0');    // 'a'
store.get('/missing');    // undefined
ParameterTypeDescription
pathstringA JSON Pointer path (e.g., /user/name)

Returns: The value at the path, or undefined if the path does not exist.

set(path, value)

Sets a single value at the given path. Performs an immutable update -- the path to the target is cloned, preserving unchanged branches. If the new value is referentially equal (===) to the current value, the update is skipped and no subscribers are notified.

store.set('/user/name', 'Bob');
store.get('/user/name'); // 'Bob'
 
// No-op if the value has not changed
store.set('/user/name', 'Bob'); // no notification
ParameterTypeDescription
pathstringA JSON Pointer path
valueunknownThe new value to set

update(updates)

Batch-sets multiple values in a single operation. Only triggers one subscriber notification, regardless of how many values change. If no values actually change, no notification is triggered.

store.update({
  '/user/name': 'Charlie',
  '/user/age': 25,
});
ParameterTypeDescription
updatesRecord<string, unknown>A map of JSON Pointer paths to new values

getSnapshot()

Returns the entire state object.

const store = signalStateStore({ x: 1, y: 2 });
store.getSnapshot(); // { x: 1, y: 2 }

Returns: StateModel -- the current state object.

subscribe(listener)

Registers a callback that is invoked after every state mutation. Returns an unsubscribe function.

const unsubscribe = store.subscribe(() => {
  console.log('State changed');
});
 
store.set('/count', 1); // logs: State changed
 
unsubscribe();
store.set('/count', 2); // no log
ParameterTypeDescription
listener() => voidCallback invoked after state changes

Returns: () => void -- an unsubscribe function.

lastChange()

Returns the path and value of the most recent mutation, or undefined before the first one. It is written before subscribers are notified, so a subscriber can read it to learn what changed -- subscribe() itself hands the listener nothing.

const store = signalStateStore({ user: { name: 'Alice' } });
 
store.lastChange();               // undefined
store.set('/user/name', 'Bob');
store.lastChange();               // { path: '/user/name', value: 'Bob' }
 
store.subscribe(() => {
  console.log('changed at', store.lastChange()?.path);
});

A write that is skipped as a no-op does not update it. update() records the last path it actually applied.

Returns: StateChangeRecord | undefined.

This is what lets <render-spec> report a real path on its stateChange render event. See the Events guide.

JSON Pointer Format

Paths follow the RFC 6901 JSON Pointer specification:

  • Paths start with /
  • Each /-separated segment traverses one level
  • Array elements are accessed by numeric index
  • Escape sequences: ~0 for ~, ~1 for /
PathDescription
/nameTop-level name property
/user/emailNested property
/items/0First array element
/items/2/namename property of third array element
/a~1bProperty named a/b

The leading / is required. '' and '/' both address the root; any other path that does not start with / throws an Error naming the path, on get(), set() and update() alike:

store.set('count', 1);
// Error: Invalid state path "count": a state store path is a JSON Pointer
// and needs a leading "/" (write "/count" to address that key, or "" for the root).

A rejected update() applies none of its entries, so a bad path in a batch leaves the state untouched.

Reactive Behavior

The store wraps state in an Angular signal(). This means:

  • Reading state via get() accesses the signal, creating a reactive dependency in any computed() or template that calls it
  • Writing state via set() or update() triggers the signal, which propagates through Angular's change detection
  • RenderSpecComponent and RenderElementComponent use computed() signals that depend on the store, ensuring the UI updates automatically
// The library internally does this:
const resolvedInputs = computed(() => {
  const ctx = { stateModel: store.getSnapshot() };
  return resolveElementProps(el.props, ctx);
});
// When store.set() fires, the signal updates, resolvedInputs recomputes,
// and NgComponentOutlet receives new inputs.

Array Handling

The store preserves array types when updating elements by index:

const store = signalStateStore({ items: ['a', 'b', 'c'] });
 
store.set('/items/1', 'B');
store.get('/items');          // ['a', 'B', 'c']
Array.isArray(store.get('/items')); // true

Immutable Updates

Every set() and update() call produces a new state object. Unchanged branches of the state tree are shared (structural sharing):

const store = signalStateStore({ a: { x: 1 }, b: { y: 2 } });
const before = store.getSnapshot();
 
store.set('/a/x', 10);
const after = store.getSnapshot();
 
before !== after;       // true -- new root
before.b === after.b;   // true -- b branch unchanged, same reference
signalStateStorefunction

Create a signal-backed StateStore for a generative-UI surface — holds the bound state that spec `$bindState` paths read and interactive elements write, with path-addressable get/set and change subscriptions. Every path is a JSON Pointer and must start with `/` (or be `''` / `'/'` for the root); a slash-less path throws.

signalStateStore(initialState: StateModel): SignalStateStore

Parameters

ParameterTypeDescription
initialState?StateModelOptional starting state object.

Returns

SignalStateStore

Examples

const store = signalStateStore({ count: 0 });
store.set('/count', 1);
store.lastChange?.(); // { path: '/count', value: 1 }

Looking for something specific?