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
| Parameter | Type | Default | Description |
|---|---|---|---|
initialState | StateModel | {} | 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| Parameter | Type | Description |
|---|---|---|
path | string | A 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| Parameter | Type | Description |
|---|---|---|
path | string | A JSON Pointer path |
value | unknown | The 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,
});| Parameter | Type | Description |
|---|---|---|
updates | Record<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| Parameter | Type | Description |
|---|---|---|
listener | () => void | Callback 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:
~0for~,~1for/
| Path | Description |
|---|---|
/name | Top-level name property |
/user/email | Nested property |
/items/0 | First array element |
/items/2/name | name property of third array element |
/a~1b | Property 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 anycomputed()or template that calls it - Writing state via
set()orupdate()triggers the signal, which propagates through Angular's change detection RenderSpecComponentandRenderElementComponentusecomputed()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')); // trueImmutable 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 referenceRelated
- State Store Guide -- usage patterns, providing stores, and working with arrays
- Specs Guide -- how
$stateexpressions connect to the store - provideRender() -- providing a global store