---
name: data-and-hydration
load-when: wiring an adia-ui app's data-flow, state ownership, content hydration, or section registration
load-size: ~3.5k tokens
required-for: [data-wiring — all modes]
---

# Data, state & hydration — patterns

Code shapes for the six data-flow patterns, the three hydration paths, and section wiring. The ownership rules (single-owner · projections-only · attribution) are the `data-wiring` rubric gates. (Claims re-verified against the kit's live source 2026-08-26; re-verify on a MINOR cut.)

## The six patterns

**1 · Signals** — fine-grained reactivity (no virtual DOM).

```js
import { signal, computed, effect } from '@adia-ai/web-components/core/signals.js';
const view = signal('live');
effect(() => render(view.value));   // re-runs on change; auto-cleans on disconnect
```

**2 · Shared app store** — state read by more than one component/module. `createStore()`
(`@adia-ai/web-components/core/store.js`) is `signal()` plus a Set-of-listeners-compatible
`subscribe(cb) -> unsubscribe` for consumers that are closures/classes rather than `effect()`
bodies — the blessed replacement for a hand-rolled `new Set()` pub/sub (reactivity review R2;
`.claude/docs/reports/2026-08-20-reactivity-review/03-app-layer-stores.md` §4). `.value`
composes with `computed()`/`effect()` like any signal; `subscribe()` is the extra imperative
channel. Interops with `UIElement`'s `controller` setter for free.

```js
import { createStore } from '@adia-ai/web-components/core/store.js';
const planStore = createStore({ items: [] });
const stop = planStore.subscribe((v) => renderPlanRail(v.items)); // imperative consumer
// stop() when that consumer is disposed — its own connectedCallback/
// disconnectedCallback teardown, a route change, etc.
el.controller = planStore;   // OR: hand it to an element via the controller-setter seam
//                               (cleans the subscription up automatically, no stop() needed)
```

Migrating an existing hand-rolled Set-of-listeners store onto this primitive is opportunistic,
one store at a time — not a required rewrite of every app-layer store at once.

**3 · Service / Controller / Command** — CRUD with undo. The Service is **async from day one** (so an in-memory impl can later swap for a remote one behind the same interface); the Controller orchestrates signals + service; Commands record patches for undo.

```js
class InMemoryTaskService { async create(d){…} async update(id,d){…} async delete(id){…} }
// swap for RemoteTaskService (same interface) → Controller + Commands unchanged
```

**4 · DataClient + mappers** — the UI reads typed **projections**, never a backend. The pure mapper is the fixtures⇄API swap seam.

```js
const labs = await client.read({ type: 'LabRecommendationSet', params });   // → projection
// mapper lives in app/shared/corpus/mappers/, pure: (sources) => Projection
await client.mutate({ type: 'order', payload }, { action_source: btn.dataset.action }); // attribution REQUIRED
```

**Attribution `[gate]`:** every `mutate` passes an `action_source`; the client throws without it.

**5 · Property-API binding** — populate catalog components by property, not children.

```js
table.columns = [{ key:'name', label:'Name', sortable:true }];
table.data    = rows;          // NOT: append <option>/<tr> children post-connect —
select.options = opts;         // the element auto-stamps its slots at connected(); later-
                               // appended children land OUTSIDE the stamped popover/listbox
                               // as visible flow content and break the parent's layout
```

**6 · Declarative `data-*`** — static flows; state is CSS.

```html
<main data-auth> … </main>
<style>[data-auth] form { display: block; }</style>
```

## The three hydration paths

**SPA — self-boot.** The surface is a self-booting container; it fetches and renders itself.

```js
connected() { if (this.#booted) return; this.#booted = true; this.#load(); }
```

**SSR — server → props → client.** The framework fetches on the server, seeds components as initial props, and the client refreshes via property binding (React `ref`+`useEffect`, Vue `:prop`, Svelte `bind:`). See `host-wiring` / [ssr-integration.md](ssr-integration.md).

**Hybrid — SPA island in an SSR page.** The server renders the page and emits the island's seed as a prop/attribute; the island **registers + boots on the client** and owns its own state and in-island routing. The framework owns the page and top-level routing; the island is a self-contained SPA surface inside it.

```html
<!-- server-rendered page (SSR) -->
<analytics-panel data-seed-id="abc"></analytics-panel>
<script type="module">import('/islands/analytics-panel.js');</script>  <!-- client-only registration -->
```

Rule: exactly one route owner _per scope_ — the framework routes the page; a content-less `<router-ui>` may route tabs **inside** the island. Never let the island's router fight the framework's.

## Section registration & connection

- **Register by side-effect import** — the barrel (`@adia-ai/web-components`) or the component module; an unimported section never upgrades.
- **Data down, events up** — props in (`.rec = …`), `CustomEvent`s out; no reaching into a parent's internals.
- **Read projected children** via `logicalChildren` / `logicalSlotted` (from `@adia-ai/web-components/core/logical-children`) — `this.children` misses `${items.map(…)}` output and the `display:contents` trap.

## Shared detail drawer, per-row hydration

A list/card collection drilling into detail mounts ONE `<drawer-ui>`; each row's action writes
its payload onto the drawer (`dataset`/props) and dispatches a `hydrate` `CustomEvent` before
`open = true`. The drawer re-renders from its own state on `hydrate` and never knows which row
fired — N drawers for N rows is a defect.

## Routing & state ownership

- **Content-less `<router-ui>`** for in-DOM/in-island tabs: routes _without_ `content`; CSS shows the active view. A content-mode route fetches + `innerHTML`-replaces — wrong for stamped views.
- **Own the URL** when you need query params: `history.replaceState(...)` and reflect `data-route-path` yourself; don't set `router.routes` (it path-routes and clobbers query params).
- **Single owner** per piece of state — the ownership assignments are the `data-wiring` rubric gates; the mechanic: a control mutates the route, an observer/CSS reflects it back — never a second source of truth.
- **Never reset user-set state from a sibling control** — changing one selector (engine, tab) must not auto-reset an unrelated user-controlled one (mode, theme), even if the new selection ignores that setting.

## Subscribe-delivery timing — never assumed uniform

`subscribe(cb)` does not mean the same thing across these patterns — read the actual store before
assuming a caller gets a value the moment `subscribe()` returns (reactivity review,
`.claude/docs/reports/2026-08-20-reactivity-review/03-app-layer-stores.md` §1/§4):

- **plan-store** delivers the current snapshot **synchronously**, inside `subscribe()` itself,
  before the caller sees the returned unsubscribe function (`plan-store.js` — `subscribe(cb) {
  listeners.add(cb); cb(items.slice()); return … }`).
- **DataClient** delivers the first snapshot **asynchronously**, via `read(query).then(handler)`
  — and **silently skips that first delivery if the read rejects** (`.catch(() => {})`
  swallows the error with no handler call at all). Don't assume a `DataClient.subscribe()`
  caller has data yet on the next line; a rejected first read is a silent no-op, not a visible
  error — worth an explicit loading/error affordance rather than trusting the subscribe channel
  alone.
- **`createStore()`** (the blessed shared-app-store primitive above, gh#1761/PR #1775)
  delivers **nothing** on subscribe: `subscribe(cb)` only adds `cb` to the listener set and
  returns the unsubscribe function — no synchronous call, no queued microtask delivery. A
  consumer that needs the current value reads `.value`/`.peek()` itself (typically once, at
  connect/render time); `subscribe()` only notifies of *later* changes. This is why the
  `UIElement.controller` setter interop works with no special-casing — the element's own render
  reads `.value` directly, and the controller's `subscribe()` callback only triggers a re-render.

Until the store-migration sweep reaches them, plan-store and DataClient keep their own delivery
timing — `createStore()` is the norm going forward for *new* shared state, not a retrofit.

## Race control — the supersede-token pattern

Three home-grown last-write-wins mechanisms independently solve the same problem — an
in-flight async operation completing after a newer one has already superseded it — with no
shared idiom (reactivity review, `03-app-layer-stores.md` §4 tail):

- `adia-embed-labs.js`'s `#summaryGen` — a private counter incremented before starting an async
  phase; the counter's value at start is captured (`const gen = ++this.#summaryGen`) and checked
  again once the async work resolves (`if (gen !== this.#summaryGen) return;`) — a mismatch means
  a newer call already took over, so the stale completion is dropped silently.
- `site.js`'s `_routeResolveSeq` — the same shape guarding a route-template resolution against a
  navigation that fires again before the first one finishes.
- The A2UI renderer's `generationId` (`beginSurfaceUpdate`/`commitSurfaceUpdate`,
  `packages/gen-ui/a2ui/renderer.js`) — the same shape at the surface-lifecycle level, minted per
  update and checked at commit time.

**The idiom, generalized:** capture a token (an incrementing counter, or a minted id) at the
start of the async operation; before applying its result, compare the captured token against the
current one; a mismatch means a newer operation superseded this one — drop the result, never
apply it. Reach for this whenever an async completion could apply out of order (a fetch, a
generation call, a route resolution) — a fresh `#gen`/`#seq` field next to the async method, not
a new home-grown class, is enough; there is no shared helper for this yet (opportunistic to add
next to `createStore()` if a fourth call site needs it — none of the three above have been
migrated onto one).
