Getting Started
This page teaches @dirtytalk/structural by doing. You will build a small state container with no framework, watch it notify subscribers on a microtask flush, and then wire the same container into React so that a component re-renders only when the exact fields it read actually change. It is written for anyone who wants fine-grained, path-level change tracking for object state.
What this package gives you
Section titled “What this package gives you”@dirtytalk/structural is a state container for structural data — plain objects and arrays whose consumers care about specific named paths (user.name, items, count). When you mutate the state, it works out which paths actually changed and wakes only the consumers that read those paths. A component that read state.count will not re-render because state.label changed.
It is built on @dirtytalk/engine, which provides the dirty-tracking channel and the schedulers that decide when notifications fire. The engine is a runtime dependency — you will import schedulers from it directly.
Install
Section titled “Install”@dirtytalk/structural depends on @dirtytalk/engine. Install both. React is an optional peer dependency, only needed if you use the /react subpath.
pnpm add @dirtytalk/structural @dirtytalk/enginenpm install @dirtytalk/structural @dirtytalk/engineyarn add @dirtytalk/structural @dirtytalk/engineA core walkthrough (no React)
Section titled “A core walkthrough (no React)”Let’s build a container by subclassing StructuralContainer<S>, drive it with patch/emit/update, subscribe to it through its channel, and observe a flush.
1. Subclass StructuralContainer<S>
Section titled “1. Subclass StructuralContainer<S>”StructuralContainer<S> is abstract. You subclass it, type its state shape, and add your own methods that call the protected-by-convention mutators (patch, emit, update).
import { StructuralContainer } from '@dirtytalk/structural';import { SyncScheduler } from '@dirtytalk/engine';
interface CounterState { count: number; label: string;}
class Counter extends StructuralContainer<CounterState> { increment() { // patch merges a DeepPartial<S> into the current state. this.patch({ count: this.state.count + 1 }); }
rename(label: string) { this.patch({ label }); }}
// The constructor takes (initial, options). For deterministic, synchronous// behaviour (tests, SSR, this walkthrough) pass a SyncScheduler from the engine.const counter = new Counter( { count: 0, label: 'clicks' }, { scheduler: new SyncScheduler(), },);2. Subscribe through the channel using ALL_PATHS
Section titled “2. Subscribe through the channel using ALL_PATHS”Every container exposes a subscribe(interest, cb) method that passes straight through to the underlying DirtyChannel. interest is a thunk returning the set of paths this subscriber cares about. For a devtools-style subscriber that wants to hear about every change, return the ALL_PATHS sentinel.
import { ALL_PATHS } from '@dirtytalk/structural';
const unsubscribe = counter.subscribe( () => ALL_PATHS, // "wake me on any change" (dirty) => { console.log('state is now', counter.state, 'dirty:', dirty); },);The callback receives dirty, the set of path IDs that changed (or ALL_PATHS). Because we used a SyncScheduler, the callback fires synchronously the moment a mutation marks the channel.
counter.increment(); // logs: state is now { count: 1, label: 'clicks' } ...counter.rename('taps'); // logs: state is now { count: 1, label: 'taps' } ...
unsubscribe(); // stop listening3. Observe a microtask flush
Section titled “3. Observe a microtask flush”Switch to the default scheduler to see coalescing. Drop the scheduler option (or pass new MicrotaskScheduler()), and multiple mutations in one synchronous tick collapse into a single deferred notification.
import { StructuralContainer, ALL_PATHS } from '@dirtytalk/structural';
const c = new Counter({ count: 0, label: 'clicks' }); // default: MicrotaskScheduler
let flushes = 0;c.subscribe( () => ALL_PATHS, () => { flushes += 1; },);
c.increment();c.increment();c.increment();
console.log(flushes); // 0 — nothing has flushed yet, still in this tick
await Promise.resolve(); // let the microtask run
console.log(flushes); // 1 — three marks coalesced into one flushThis is the same flush/coalescing model the engine documents in Engine: Concepts: marks accumulate via set union, a flush is requested from the scheduler, and the subscriber sees the unioned dirty region exactly once.
A React walkthrough
Section titled “A React walkthrough”The React adapter lives at the @dirtytalk/structural/react subpath and exposes a single hook, useStructural. It returns [state, container], where state is a recording proxy: reading a field off it during render registers that path as this component’s interest, so only changes to paths it read trigger a re-render.
import { useStructural } from '@dirtytalk/structural/react';
function CountButton({ counter }: { counter: Counter }) { const [state, container] = useStructural(counter); // Reading state.count registers the path "count" for this component. return ( <button onClick={() => container.increment()}> clicked {state.count} times </button> );}
function LabelTag({ counter }: { counter: Counter }) { const [state] = useStructural(counter); // This component reads only state.label, so it registers "label". return <span>{state.label}</span>;}Now the isolation pays off. With both components mounted against the same counter instance:
counter.increment()changescount. OnlyCountButtonre-renders.LabelTagreadlabeland stays asleep.counter.rename('taps')changeslabel. OnlyLabelTagre-renders.CountButtonstays asleep.
No selector function, no manual dependency array. The hook tracks exactly the paths your JSX touched this render, and adapts automatically when conditional reads change which fields you access. Pass the container instance in however you like — props, context, or a module singleton.
When to use this package, and how it relates to BlaC
Section titled “When to use this package, and how it relates to BlaC”Reach for @dirtytalk/structural when:
- You have object/array state with many independent consumers that each read different slices of it, and you want each consumer to re-render only when its slice changes — without writing selectors.
- You want path-level change tracking that is framework-agnostic at the core (the container and diff helpers have no React dependency) with an optional thin React adapter.
- You are building on
@dirtytalk/engineand want a higher-level, path-aware container rather than wiring aDirtyChannelby hand.
It is the spiritual successor to BlaC’s per-consumer path tracking. If you have used BlaC’s tracked state and dependency tracking in useBloc, the model here will feel familiar: read what you need, re-render when it changes. @dirtytalk/structural distills that idea into a standalone, engine-backed package. If you want the full BlaC programming model (cubits, cross-bloc dependencies, the wider ecosystem), see the BlaC introduction. If you want fine-grained structural tracking as a focused building block, this package is it.
See also
Section titled “See also”- Structural: Concepts — the why and the model: path sets, the skeleton, the tracker.
- Structural: API Reference — every export, signature, and option.
- Engine: Getting Started — the
DirtyChanneland schedulers this package is built on. - Engine: Concepts — Space algebra, scheduling, and the flush model.