React
useStructural subscribes a component to a StructuralContainer and re-renders it only when a path it read during render changes.
useStructural from @dirtytalk/structural/react binds a React component to a StructuralContainer. It records the paths read during each render and subscribes the component to exactly those.
Requirements
react >= 18(optional peer dependency of@dirtytalk/structural).@dirtytalk/engineinstalled alongside.
pnpm add @dirtytalk/structural @dirtytalk/engine react
Usage
import { StructuralContainer } from '@dirtytalk/structural';
import { useStructural } from '@dirtytalk/structural/react';
interface CounterState {
count: number;
label: string;
}
class Counter extends StructuralContainer<CounterState> {
constructor() {
super({ count: 0, label: 'counter' });
}
increment() {
this.patch({ count: this.state.count + 1 });
}
rename(label: string) {
this.patch({ label });
}
}
const counter = new Counter();
function CountButton() {
const [state, container] = useStructural(counter);
return <button onClick={() => container.increment()}>{state.count}</button>;
}
function Label() {
const [state] = useStructural(counter);
return <h2>{state.label}</h2>;
}
container.increment() re-renders CountButton only. container.rename('x') re-renders Label only.
Signature
function useStructural<S, C extends StructuralContainer<S>>(
container: C,
options?: UseStructuralOptions,
): readonly [S, C];
| Parameter | Description |
|---|---|
container | The container to read. Its identity should be stable across renders; a new container resubscribes. |
options | UseStructuralOptions. Currently only select?: never; there are no options to set. |
Returns a [state, container] tuple. state is a recording proxy over container.state; container is the same instance you passed in, typed as C, for calling its methods.
How it works
- On each render the hook calls
trackRenderwith the container’s interner and a per-hookProxyCache, and returns the proxy asstate. - Every property you read while rendering is recorded.
- In a layout effect after commit, the hook stops recording (
disarm), registers the paths withregisterConsumerPaths, and updates the subscription’s interest. If the paths are unchanged since the last commit, nothing is registered. - When the container flushes a dirty set that intersects the interest, the hook forces a re-render.
Interest therefore follows the render: if props, context or local state change which fields are read, the interest updates on the next commit. No selectors are declared.
Re-render semantics
- A component re-renders when a path it read is marked dirty. The container marks only paths whose values changed, including descendants of atomically replaced arrays.
- Reads made after the render pass (event handlers, effects, timers) are not recorded.
- Reads made during render but conditionally are recorded only when executed; the interest narrows or widens on the next commit.
- If the state changes between render and commit, or between render and the passive subscribe, the hook forces one extra render so the component does not stay stale.
- Reading a whole object (for example passing
state.userto a child that reads fields) records the object path, so the component re-renders on any change under that object. Passuntracked(state.user)to avoid it; see Patterns. - Under React StrictMode the subscription effect re-registers the last committed paths after a simulated unmount.
Patterns
Comparing identity
Values read off state are proxies. Use raw() before comparing with raw references:
import { raw } from '@dirtytalk/structural';
import { useStructural } from '@dirtytalk/structural/react';
import { Todos } from './todos';
const todos = new Todos();
function Selected({ selected }: { selected: Todos['state']['todos'][number] }) {
const [state] = useStructural(todos);
return (
<ul>
{state.todos.map((t) => (
<li key={t.id} aria-current={raw(t) === selected}>
{t.title}
</li>
))}
</ul>
);
}
Passing snapshots to children
Pass untracked(value) so the parent stops re-rendering for paths only the child reads:
import { untracked } from '@dirtytalk/structural';
import { useStructural } from '@dirtytalk/structural/react';
import { Todos } from './todos';
const todos = new Todos();
function Item({ item }: { item: { title: string } }) {
return <li>{item.title}</li>;
}
function List() {
const [state] = useStructural(todos);
return (
<ul>
{state.todos.map((t) => (
<Item key={t.id} item={untracked(t)} />
))}
</ul>
);
}
The Item above is a snapshot: it does not update itself when the title changes. Have it call useStructural itself when it must be reactive.
Derived values
Compute derived values in the component from the tracked state, or in a container getter. There is no computed or memo layer in this package.
Pitfalls
- Escaped proxies. Do not store
stateor values read from it in refs, module variables or long-lived closures. Callraw()first. See tracking hazards. - Writing to state. Assigning through
statethrows in development. Call a container method instead. - Mutating in place. Containers must replace objects immutably; see immutability rules.
- Class instances,
Map,Set,Date. These are leaves. Readingstate.map.get('k')recordsmaponly, and wakes on a newMapreference. - Creating containers in render.
new Counter()inside a component creates a new container every render. Create it outside, or withuseState/useMemo. - Reads in
useMemo. A memo that skips the reads records nothing in that render. Keep reads in the render body, or read throughraw()deliberately.