@uistate/view
v1.0.0
Published
State-driven view: DOM structure as first-class state. DOMless resolve + surgical project.
Maintainers
Readme
@uistate/view
State-driven view: DOM structure as first-class state.
The UI is not a function of state. The UI is state.
The Idea
Traditional frameworks treat the virtual DOM as an ephemeral, opaque computation:
render(state) -> vDOM -> reconcile -> real DOMYou can't inspect it, subscribe to it, or test it.
@uistate/view inverts this. The view specification lives in the EventState store as a normalized tree of nodes, each independently subscribable via dot-paths. Data and view coexist in the same store, tested with the same assertPath.
state -> view tree (persistent, observable, first-class state) -> projector -> real DOMArchitecture
Two phases, inspired by the normalized state pattern from Ch15 (Pro State Management in JS):
Phase 1: resolve.js; Pure, DOMless (runs in Node)
normalize(nestedTree); Flattens a nested view specification into a map of nodes by stable ID. Each node hastag,childIds,parentId, and all attributes/bindings. This is the Ch15 pattern applied to the view layer.flatten(tree, store, prefix); Normalizes and writes into an EventState store viasetMany(atomic).resolveTree(nodes, rootId, getData); Evaluates expressions, expandsforEach, resolves text interpolation. Pure function.serialize(resolvedTree); Converts a resolved tree to an HTML string (SSR/snapshots).
Phase 2: project.js; DOM Projector (browser only)
mount(store, prefix, container, handlers): Reads normalized nodes from the store, creates DOM elements, subscribes to each node's path for surgical updates. Follows the Ch15createNodeElement(nodeId)pattern.
Install
npm install @uistate/viewPeer dependency: @uistate/core >= 5.0.0
Usage
Define state + view in one store
import { createEventState } from '@uistate/core';
import { flatten } from '@uistate/view/resolve';
const store = createEventState({
todos: [
{ id: 1, text: 'Learn SDD', done: true },
{ id: 2, text: 'Build @uistate/view', done: false }
],
inputText: ''
});
flatten({
tag: 'div',
class: 'todo-app',
children: [
{ tag: 'h2', text: 'Todo ({todos.length})' },
{
tag: 'ul',
forEach: 'todos',
as: 'todo',
template: {
tag: 'li',
classIf: { done: 'todo.done' },
children: [
{ tag: 'span', text: 'todo.text' },
{ tag: 'button', text: 'x', onClick: 'deleteTodo(todo.id)' }
]
}
},
{
tag: 'div',
class: 'stats',
children: [
{ tag: 'span', text: 'Total: {todos.length}' },
{ tag: 'span', text: 'Done: {todos.filter(t => t.done).length}' }
]
}
]
}, store, 'view');After flatten, the store contains:
view.rootId -> 'v0'
view.nodes.v0 -> { id: 'v0', tag: 'div', class: 'todo-app', childIds: ['v1','v2','v3'], ... }
view.nodes.v1 -> { id: 'v1', tag: 'h2', text: 'Todo ({todos.length})', ... }
view.nodes.v2 -> { id: 'v2', tag: 'ul', forEach: 'todos', template: {...}, ... }
...Each node is independently subscribable:
store.subscribe('view.nodes.v1', (node) => { /* only h2 changed */ });
store.subscribe('view.nodes.*', (detail) => { /* any node changed */ });DOMless testing (Node, no browser)
import { resolveTree } from '@uistate/view/resolve';
const nodes = store.get('view.nodes');
const rootId = store.get('view.rootId');
const resolved = resolveTree(nodes, rootId, (path) => store.get(path));
// Assert on the resolved tree — no DOM needed
assert(resolved.children[0].text === 'Todo (2)');
assert(resolved.children[1].children.length === 2);
assert(resolved.children[1].children[0].children[0].text === 'Learn SDD');SSR (serialize to HTML)
import { serialize } from '@uistate/view/resolve';
const html = serialize(resolved);
// <div class="todo-app">
// <h2>Todo (2)</h2>
// <ul>
// <li><span>Learn SDD</span>...</li>
// ...
// </ul>
// ...
// </div>Browser projection
import { mount } from '@uistate/view/project';
const cleanup = mount(store, 'view', document.getElementById('app'), {
addTodo: () => { /* ... */ },
deleteTodo: (id) => { /* ... */ },
toggleTodo: (id) => { /* ... */ }
});
// cleanup() removes all subscriptions and DOMView Specification
Node properties
| Property | Type | Description |
|----------|------|-------------|
| tag | string | HTML tag name |
| class | string | CSS class(es) |
| classIf | object | Conditional classes: { done: 'todo.done' } |
| text | string | Text content, supports {expression} interpolation and data references |
| type | string | Input type |
| placeholder | string | Input placeholder |
| bind | string | Two-way binding to a store path |
| onClick | string | Handler name: 'addTodo' or 'deleteTodo(todo.id)' |
| onEnter | string | Handler on Enter key |
| children | array | Child node specifications |
| forEach | string | Store path to iterate |
| as | string | Loop variable name (default: 'item') |
| template | object | Template spec for each item |
Expressions
Text supports {expression} interpolation:
- Path lookup:
{todos.length}-> array length - Filter count:
{todos.filter(t => t.done).length}-> filtered count - Data reference:
todo.text(in forEach context) -> item property
How it works (normalized state)
The nested view tree you author:
{ tag: 'div', children: [{ tag: 'h1', text: 'Hello' }, { tag: 'p', text: 'World' }] }Gets normalized into a flat map (Ch15 pattern):
{
'v0': { id: 'v0', tag: 'div', childIds: ['v1', 'v2'], parentId: null },
'v1': { id: 'v1', tag: 'h1', text: 'Hello', childIds: [], parentId: 'v0' },
'v2': { id: 'v2', tag: 'p', text: 'World', childIds: [], parentId: 'v0' }
}Each node is an independent entry in EventState. Updating v1.text fires only view.nodes.v1 subscribers, not the parent, not siblings. This is O(1) reactivity, exactly like the tree app in Ch15.
Testing
Two-layer testing architecture:
self-test.js: Zero-dependency self-test (73 assertions). Runs automatically on npm install via postinstall. Tests the pure resolve module: normalize, resolveNode, resolveTree, serialize, flatten, getByPath, interpolate. Includes the full SDD todo app as the final integration test.
node self-test.jstests/view.test.js: Integration tests via @uistate/event-test (14 tests). Tests flatten into EventState, store-driven resolve, surgical updates, node independence, SSR, and the full SDD workflow.
npm test| Suite | Assertions | Dependencies |
|-------|-----------|-------------|
| self-test.js | 73 | @uistate/core only |
| tests/view.test.js | 14 | @uistate/event-test, @uistate/core |
Comparison
| Concern | React | @uistate/renderer | @uistate/view |
|---------|-------|-------------------|---------------|
| View representation | Ephemeral vDOM | HTML attributes | First-class state |
| Subscribable? | No | Per-binding | Per-node |
| DOMless testing? | No (needs jsdom) | Partial (pure helpers) | Complete |
| SSR? | Framework-specific | No | serialize() |
| Reactivity | Component-level | Per-attribute | Per-node (O(1)) |
| Source of truth | Code (JSX) | DOM (HTML) | State (JSON in store) |
License
MIT
