State Store
The state store holds the reactive state that drives your rendered UI. @threadplane/render provides signalStateStore(), an Angular Signals-backed implementation of the StateStore interface from @json-render/core.
Creating a State Store
import { signalStateStore } from '@threadplane/render';
const store = signalStateStore({
user: { name: 'Alice', age: 30 },
items: ['apple', 'banana', 'cherry'],
isVisible: true,
});The function accepts an optional initial state object (defaults to {}). It returns a StateStore that uses Angular Signals internally, so any state change automatically triggers Angular's change detection.
JSON Pointer Paths
All state access uses JSON Pointer paths. A JSON Pointer is a string that identifies a specific value within a JSON document.
| Path | Resolves to |
|---|---|
/user/name | 'Alice' |
/user/age | 30 |
/items/0 | 'apple' |
/items/2 | 'cherry' |
/isVisible | true |
Paths always start with /. Each segment separated by / traverses one level deeper into the object. Array elements are accessed by index.
Escaping
JSON Pointer defines two escape sequences for special characters in property names:
~0represents~~1represents/
For example, to access a property named a/b, the pointer would be /a~1b.
Reading State
Use get() to read a value at a path:
const store = signalStateStore({ user: { name: 'Alice' } });
store.get('/user/name'); // 'Alice'
store.get('/user'); // { name: 'Alice' }
store.get('/missing'); // undefinedWriting State
Single Value
Use set() to write a single value. The store performs an immutable update -- it clones the path to the target and sets the new value. If the new value is referentially equal to the current one, the update is skipped.
store.set('/user/name', 'Bob');
store.get('/user/name'); // 'Bob'Batch Updates
Use update() to set multiple values in a single operation. This triggers only one notification to subscribers, regardless of how many values change.
store.update({
'/user/name': 'Charlie',
'/user/age': 25,
'/isVisible': false,
});If none of the values actually change (all are referentially equal), no notification is triggered.
Snapshots
Use getSnapshot() to get the entire state object:
const store = signalStateStore({ x: 1, y: 2 });
store.getSnapshot(); // { x: 1, y: 2 }
store.set('/x', 10);
store.getSnapshot(); // { x: 10, y: 2 }Subscribing to Changes
Use subscribe() to register a callback that is invoked whenever the state changes. The function returns an unsubscribe function.
const store = signalStateStore({ count: 0 });
const unsubscribe = store.subscribe(() => {
console.log('State changed:', store.getSnapshot());
});
store.set('/count', 1); // logs: State changed: { count: 1 }
store.set('/count', 2); // logs: State changed: { count: 2 }
unsubscribe(); // stop listening
store.set('/count', 3); // no log -- unsubscribedReactive Behavior with Angular Signals
Under the hood, signalStateStore() wraps the state in an Angular signal(). This means:
- Components using
OnPushchange detection automatically update when the state changes - Props resolved via
$stateexpressions in specs are re-evaluated when the underlying signal updates - The store fits Angular's reactivity model -- no RxJS or manual subscription management needed
// In a spec, $state props are automatically reactive
{
type: 'Text',
props: {
label: { $state: '/user/name' }, // re-evaluated on state change
},
}Working with Arrays
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/1'); // 'B'
store.get('/items'); // ['a', 'B', 'c']
// The items value is still an array
Array.isArray(store.get('/items')); // trueProviding the Store
Let's wire the store in. You have three ways to provide one, and RenderSpecComponent resolves it using this priority chain:
Pass a store directly to <render-spec>:
<render-spec [spec]="spec" [store]="store" />Set a store in provideRender():
provideRender({
registry: myRegistry,
store: signalStateStore({ theme: 'dark' }),
})If no external store is provided, RenderSpecComponent creates an internal signalStateStore() from spec.state:
const spec: Spec = {
root: 'root',
elements: { /* ... */ },
state: { message: 'Hello' }, // used to create an internal store
};Testing Rendered Output
Because signalStateStore() is a plain factory and RenderSpecComponent is a standard standalone component, you can test the full render path in TestBed with no server and no LLM. Build a spec, mount <render-spec> with a store you control, mutate the store, and assert the projected component updated.
This spec renders a Text component bound to /message, then checks that writing the store re-renders it:
import { Component, input } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { RenderSpecComponent, defineAngularRegistry, signalStateStore } from '@threadplane/render';
import type { Spec } from '@json-render/core';
@Component({
selector: 'app-text',
standalone: true,
template: `<span data-testid="text">{{ label() }}</span>`,
})
class TextComponent {
readonly label = input<string>('');
}
describe('render output', () => {
it('re-renders when the store changes', () => {
const spec: Spec = {
root: 'msg',
elements: {
msg: { type: 'Text', props: { label: { $state: '/message' } } },
},
};
const store = signalStateStore({ message: 'hello' });
const registry = defineAngularRegistry({ Text: TextComponent });
const fixture = TestBed.createComponent(RenderSpecComponent);
fixture.componentRef.setInput('spec', spec);
fixture.componentRef.setInput('registry', registry);
fixture.componentRef.setInput('store', store);
fixture.detectChanges();
const span = (): HTMLElement =>
fixture.nativeElement.querySelector('[data-testid="text"]');
expect(span().textContent?.trim()).toBe('hello');
store.set('/message', 'updated');
fixture.detectChanges();
expect(span().textContent?.trim()).toBe('updated');
expect(store.get('/message')).toBe('updated');
});
});The same pattern covers handlers (assert the store after the rendered component calls emit), visibility (toggle a $state flag and assert the element appears or disappears), and repeat loops (set an array and count the rendered rows).