React

useStructural subscribes a component to a StructuralContainer and re-renders it only when a path it read during render changes.

View .md Edit on GitHub

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/engine installed 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];
ParameterDescription
containerThe container to read. Its identity should be stable across renders; a new container resubscribes.
optionsUseStructuralOptions. 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

  1. On each render the hook calls trackRender with the container’s interner and a per-hook ProxyCache, and returns the proxy as state.
  2. Every property you read while rendering is recorded.
  3. In a layout effect after commit, the hook stops recording (disarm), registers the paths with registerConsumerPaths, and updates the subscription’s interest. If the paths are unchanged since the last commit, nothing is registered.
  4. 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.user to a child that reads fields) records the object path, so the component re-renders on any change under that object. Pass untracked(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 state or values read from it in refs, module variables or long-lived closures. Call raw() first. See tracking hazards.
  • Writing to state. Assigning through state throws 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. Reading state.map.get('k') records map only, and wakes on a new Map reference.
  • Creating containers in render. new Counter() inside a component creates a new container every render. Create it outside, or with useState/useMemo.
  • Reads in useMemo. A memo that skips the reads records nothing in that render. Keep reads in the render body, or read through raw() deliberately.