# @modular-react/compositions Arrange several modules (and journeys) into named **zones** on a single screen, driven by a per-instance scoped store. Zones are pure projections of state — a selector inspects the composition's state and returns "render module X's entry Y here", "mount journey Z here", or "nothing". Use this package when one screen orchestrates several modules with **shared coordination state**, but no inherent transition graph — e.g. an editor with a main canvas, a left integrations panel, and a right inspector that all need to agree on `documentId` / `selectedItem` / `activeIntegration` but each is otherwise free-standing. If you instead need a stepped flow ("complete A → branch into B or C → finalize"), reach for [`@modular-react/journeys`](../journeys/README.md). Journeys and compositions interoperate: a zone can host a journey, and a journey step can host a composition (via plain ``). ## Prerequisite reading - [Shell Patterns (Fundamentals)](../../docs/shell-patterns.md) - [Sibling modules sharing a screen](../../docs/sibling-modules-shared-screen.md) — the simpler "plain React + per-module config" pattern. If that's enough, you don't need compositions. ## Contents - [Installation](#installation) - [Mental model](#mental-model) — three roles, what the composition owns vs the host vs the panels - [Quickstart](#quickstart) — the 5-step path from zero to a running composition - [Core concepts](#core-concepts) — zones, selectors, instance statuses, lifecycle, disposal - [Authoring patterns](#authoring-patterns) — selector idioms, dispatch from panels, cross-zone hand-offs - [Composing journeys inside zones](#composing-journeys-inside-zones) — when a zone needs a stepped flow - [Runtime surface](#runtime-surface) — `CompositionRuntime` - [Composition handles](#composition-handles) — typed tokens for `runtime.start(handle, input)` - [`CompositionsProvider` + context](#compositionsprovider--context) - [Rendering — `CompositionOutlet`](#rendering--compositionoutlet) — props, render-prop, error policies, preload - [Host hook — `useComposition`](#host-hook--usecomposition) — mint an instance for the route / tab / modal that mounts the outlet - [Hooks for foreign panels](#hooks-for-foreign-panels) — `useCompositionState` / `Dispatch` / `Emit` / `Zone`, typed-bundle factory - [Validation](#validation) — definition-time + resolve-time checks - [Hydration](#hydration) — attaching an SSR or debug-dump blob - [Cycle safety](#cycle-safety) — composition ↔ journey nesting - [Errors, races, and edge cases](#errors-races-and-edge-cases) - [Limitations](#limitations) - [Comparison with journeys](#comparison-with-journeys) ## Installation ```bash pnpm add @modular-react/compositions ``` Peer deps: `@modular-react/core`, `@modular-react/react`, `react`, `react-dom`. `@modular-react/journeys` is **not** a peer dependency. Compositions stays journeys-agnostic at the package level — `kind: "journey"` zone resolutions are wired through a generic [`RuntimeMountAdapter`](#runtime-mount-adapters) the consumer registers explicitly. If your app uses journey-in-zone, install `@modular-react/journeys` and call `registerMountAdapter("journey", createJourneyMountAdapter(...))` once at startup. See [Composing journeys inside zones](#composing-journeys-inside-zones). ## Using this with Vue There's a full Vue 3 binding: **`@modular-vue/compositions`** (peer deps `@modular-frontend/core`, `@modular-vue/vue`, `vue`). The composition _engine_ — `defineComposition`, the runtime, scoped stores, validation, zone selectors, and every type — is framework-neutral (it lives in `@modular-frontend/compositions-engine`) and is re-exported identically by both bindings, so **composition definitions and zone selectors are written exactly the same way** whichever framework you ship. What changes is the UI layer: - Import `CompositionOutlet`, `CompositionsProvider`, `compositionsPlugin()`, `useComposition`, and the panel composables (`useCompositionState`, `useCompositionDispatch`, `useCompositionEmit`, `useCompositionZone`) from `@modular-vue/compositions`. - The React outlet's render-prop `children(zones)` becomes a **scoped default slot**: ``. The host still owns layout; the framework owns each zone's content. - Panel composables return **Vue refs** (e.g. `useCompositionState` → `ShallowRef`), matching the rest of the Vue binding. See [`examples/vue/editor-composition`](../../examples/vue/editor-composition) for a runnable three-zone composition, and [Getting started with Vue Router](../../docs/getting-started-vue-router.md) for the surrounding shell setup. The React code below uses React imports and JSX; translate the imports, the render-prop to a scoped slot, and the panels to SFCs for Vue — the composition and zone-selector contracts are unchanged. ## Mental model Three roles, strictly separated: 1. **Modules** declare what they render (`entryPoints`) and what they emit (`exitPoints`). They know **nothing** about the composition that hosts them — a panel rendered as part of an "editor" composition is the same module that might be rendered standalone on a different route. 2. **The composition** owns a scoped store (`TState`), declares one **zone** per layout slot, and provides a pure **selector** per zone that maps state to "what should render here right now". 3. **The host** (a route Component, a tab, a modal, anywhere) calls `runtime.start(handle, input)` to get an `instanceId`, then renders `` with a layout render-prop that arranges the zones however it wants. The composition's store is the **orchestration bus**. Panels exchange data with it via either: - **Typed store contracts** — the selector projects state into `ReadableStore` / `WritableStore` (from `@modular-react/core`) and hands them to the panel via `input`. The panel imports only the structural store interface — recommended when panels and the composition are owned by different teams. - **Composition hooks** — `useCompositionState` / `useCompositionDispatch` read and write directly. Recommended when the same team owns both. Both patterns subscribe at slice level (via `useSyncExternalStore` under the hood) — they differ in coupling, not performance. See [Hooks vs stores — which to use](#hooks-vs-stores--which-to-use). ```text ┌──────────────────── Host (route / tab / modal) ─────────────────┐ │ │ │ ┌───────── ──────────┐ │ │ │ │ │ │ │ children: (zones) => layout JSX │ │ │ │ ↑ { │ │ │ │ │ editorMain: ⟶ render │ │ │ │ │ integrationSource: ⟶ render │ │ │ │ │ inspector: ⟶ render fallback (empty) │ │ │ │ │ } │ │ │ │ │ │ │ │ │ └── computed by per-zone selectors over scoped store│ │ │ │ │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ └──────────────────────────────────────────────────────────────────┘ ``` ## Quickstart ### 1. Declare panel modules normally Modules are the same `defineModule` you already use. Compositions don't change anything about how a module is authored — its entries and exits are framework-agnostic. ```typescript // packages/editor/src/index.ts import { defineModule, defineEntry, defineExit, schema } from "@modular-react/core"; import { EditorMain } from "./EditorMain.js"; export const editorModule = defineModule({ id: "editor", version: "1.0.0", exitPoints: { saved: defineExit() }, entryPoints: { main: defineEntry({ component: EditorMain, input: schema<{ documentId: string }>(), }), }, }); ``` ### 2. Define the composition's state and zones `defineComposition` is a two-call helper. The first call pins `TModules` + `TState`; the second takes the definition with `const`-narrowed zone names. ```typescript // packages/editor-composition/src/index.ts import { defineComposition, defineCompositionHandle } from "@modular-react/compositions"; import type { editorModule } from "@myorg/editor"; import type { contentfulModule } from "@myorg/contentful"; import type { strapiModule } from "@myorg/strapi"; type EditorModules = { readonly editor: typeof editorModule; readonly contentful: typeof contentfulModule; readonly strapi: typeof strapiModule; }; export interface EditorState { readonly documentId: string; readonly activeIntegrationId: "contentful" | "strapi" | null; readonly selectedSourceItem: string | null; } export const editorComposition = defineComposition()({ id: "editor", version: "1.0.0", initialState: (input: { documentId: string }) => ({ documentId: input.documentId, activeIntegrationId: null, selectedSourceItem: null, }), zones: { editorMain: { select: ({ state }) => ({ kind: "module-entry", module: "editor", entry: "main", input: { documentId: state.documentId }, }), }, integrationSource: { select: ({ state }) => state.activeIntegrationId ? { kind: "module-entry", module: state.activeIntegrationId, entry: "sourcePanel", input: { documentId: state.documentId }, } : { kind: "empty" }, fallback: () =>

Pick an integration to load assets.

, }, }, }); // Optional typed handle — gives `runtime.start(editorHandle, input)` full type-check on input. export const editorHandle = defineCompositionHandle<"editor", { documentId: string }>({ id: "editor", }); ``` ### 3. Register the composition in the shell ```typescript // shell/src/registry.ts import { createRegistry } from "@modular-react/core"; import { compositionsPlugin } from "@modular-react/compositions"; import { editorComposition } from "@myorg/editor-composition"; import { editorModule } from "@myorg/editor"; import { contentfulModule } from "@myorg/contentful"; import { strapiModule } from "@myorg/strapi"; export const registry = createRegistry({ plugins: [compositionsPlugin()] }) .registerModule(editorModule) .registerModule(contentfulModule) .registerModule(strapiModule) .registerComposition(editorComposition); export const manifest = registry.resolve(); ``` `compositionsPlugin()` contributes `registerComposition(...)` onto the registry. The plugin validates structural mistakes immediately and cross-references contracts + `moduleCompat` at `resolve()` time. The runtime is exposed as `manifest.extensions.compositions`. ### 4. Mount the composition in a route / tab / modal ```typescript // shell/src/routes/editor.tsx import { CompositionOutlet, CompositionsProvider, useComposition, } from "@modular-react/compositions"; import { editorHandle, type EditorState } from "@myorg/editor-composition"; import { manifest } from "../registry.js"; export function EditorRoute({ documentId }: { documentId: string }) { // `useComposition` mints the instance exactly once for the lifetime of // this route component and returns its id. Disposal is automatic — when // this component unmounts, `` releases its refcount // and the runtime ends the instance after a microtask. **Do not** wrap // `runtime.start()` in `useEffect` or `useMemo` yourself; `useEffect` // is the "you might not need an effect" anti-pattern (it round-trips // through setState before commit), and `useMemo` is documented as a // pure optimization hint that React may re-invoke at will — a fresh // start() call on every re-run would orphan the previous instance. const instanceId = useComposition(editorHandle, { documentId }); return ( compositionId="editor" instanceId={instanceId} > {(zones) => (
{zones.editorMain}
)}
); } ``` The render-prop receives one `ReactNode` per zone, fully wrapped (`Suspense` + per-zone error boundary already applied). The host owns layout; the framework owns content. > `useComposition` reads its runtime from the surrounding `` — wired automatically when you use `compositionsPlugin()`. If you're mounting the outlet without the plugin, pass `useComposition(handle, input, useCompositionOptions({ runtime }))` to bypass the context lookup. ### 5. Drive the composition from inside a panel Two supported patterns — pick by team ownership: **Recommended for cross-team scenarios — typed store contracts.** The composition's selector projects state into `ReadableStore` / `WritableStore` (from `@modular-react/core`) and hands them to panels via `input`. The panel imports only the structural store interface — _nothing_ composition-specific — and reads via `useSyncExternalStore`: ```typescript // editor-composition/src/composition.ts — selector projects state into a store zones: { source: { select: ({ state, stores }) => ({ kind: "module-entry", module: state.activeSource, entry: "sourcePanel", input: { documentId: state.documentId, // Stable per (instance, "selectedSourceItem") — same reference // across selector re-runs, so useSyncExternalStore doesn't // re-subscribe. selectedSourceItem: stores.writable("selectedSourceItem", { get: (s) => s.selectedSourceItem, set: (value) => ({ selectedSourceItem: value }), }), }, }), }, }, ``` ```typescript // contentful/src/SourcePanel.tsx — module imports zero composition types import { useSyncExternalStore } from "react"; import type { ModuleEntryProps, WritableStore } from "@modular-react/core"; interface SourcePanelInput { readonly documentId: string; readonly selectedSourceItem: WritableStore; } export function ContentfulSourcePanel({ input }: ModuleEntryProps) { const selected = useSyncExternalStore( input.selectedSourceItem.subscribe, input.selectedSourceItem.getSnapshot, ); return (
    {items.map((it) => (
  • input.selectedSourceItem.set(it.id)} > {it.title}
  • ))}
); } ``` The panel team and composition team share only the structural `WritableStore` contract. The composition's `TState` shape can change without touching the panel; a different host can supply a different `WritableStore` (test mock, shell-level Zustand store, etc.). See [Pattern — typed store projections](#pattern--typed-store-projections-composition-unaware-panels) for the full design. **Alternative for same-team scenarios — composition hooks.** When one team owns both the composition and the panels, `useCompositionState(selector)` and `useCompositionDispatch()` skip the typed-store layer — selectors return slices of `TState` directly, panels read/write through the hooks. See [Hooks for foreign panels](#hooks-for-foreign-panels) for the full code and the typed-hooks factory; trade-offs in [Hooks vs stores — which to use](#hooks-vs-stores--which-to-use). ## Core concepts ### Composition zones vs `module.zones` The framework has two distinct primitives that both use the word "zone." They are unrelated. | | `module.zones` (existing) | composition zones (this package) | | --------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | | Declared by | `defineModule({ zones: { ... } })` and the router's route `staticData` | `defineComposition({ zones: { ... } })` | | Populated by | The _active route's_ module — at most one component per zone | The composition's per-zone `select(ctx)`, which can target any registered module | | Cardinality | One contribution per zone at a time (most-recent active wins) | Many panels mounted in parallel, one per declared zone | | Layout owner | The shell — `useZones` / `useActiveZones` read the merged map | The host — ``'s render-prop arranges zones however it wants | | Authoring shape | String → React component | String → `select(ctx) → CompositionZoneResolution` | | Use when | A module needs to contribute a header chip, command, or one-shot slot to the active screen | Several modules need to render side-by-side on a single screen with shared coordination state | A composition does **not** participate in `module.zones`. The shell's `useZones`/`useActiveZones` will not see anything from a ``. The two systems are orthogonal — a screen can use both at once (e.g., a route uses `module.zones` for the header chip + a `` for the multi-panel body). > A **third** render surface, [subject-keyed panels](../../docs/subject-panels.md), is deliberately _not_ called a zone: it is render-**all** (not pick-one) and keyed on a caller-supplied subject rather than the route or composition state. Both "zone" meanings above are pick-one; the render-all primitive keeps its own name — see [Comparison with sibling primitives](#comparison-with-sibling-primitives). Inside a composition zone, **the composition definition owns the zone name and the selector** (what renders here, driven by state); **the host owns the layout** (where the zone appears on screen). The framework wraps each zone in `` + a per-zone error boundary before handing the `ReactNode` to the host's render-prop. ### Zones A zone is a named projection of state into one of three resolutions, declared per-render by the zone's `select(ctx)`: ```typescript type CompositionZoneResolution = | { kind: "module-entry"; module: string; entry: string; input?: unknown } | { kind: "journey"; handle: JourneyHandleRef; input?: unknown; instanceId?: string } | { kind: "empty" }; ``` - **`module-entry`** — render the named module's entry. The runtime looks the entry up in the module map and calls it with `input` as `ModuleEntryProps.input`. - **`journey`** — mount a `` for the referenced journey handle. The composition outlet caches the minted journey instance id per `(handle.id, structural hash of input)` so a state change that produces the same resolution does not re-mint. - **`empty`** — render the zone's `fallback` (a React component) or `null`. `TModules` constrains the `module` field to ids the composition's typed module map declares, so a typo is a compile error. ### Mount kinds — opting an entry out of compositions Some module entries belong to one host surface only. A panel typed for a journey step receives `exit`, `goBack`, and `goForward` — calling those inside a composition zone is a silent drop, because the composition has no exit channel (panels dispatch via `useCompositionDispatch` / `useCompositionEmit` instead). To surface that mismatch at the right moment, an entry can declare which hosts may mount it: ```typescript defineEntry({ component: CheckoutStep, input: schema<{ amount: number }>(), mountKinds: ["journey"], // journey only — composition selectors reject this entry }); defineEntry({ component: EditorPanel, input: schema<{ documentId: string }>(), mountKinds: ["composition"], // composition only — journey transitions reject this entry }); defineEntry({ component: SharedHeader, input: schema(), mountKinds: ["journey", "composition"], // both — explicit form of the default }); defineEntry({ component: AgnosticPanel, input: schema(), // mountKinds omitted → defaults to every surface; works as before. }); ``` The framework enforces this in three places: 1. **Compile time** — a composition selector that returns a `module-entry` resolution targeting a journey-only entry is a type error at the selector call site. The diagnostic enumerates the entries that ARE composition-mountable on that module, so the author can pick a different one. Symmetric on the journey side: a `StepSpec` returning a composition-only entry is a type error in the transition handler. 2. **Render time** — if a resolution somehow bypasses the type filter (a dynamic id, an `as never` cast, an `any`-typed module map), the composition outlet renders a clear error fallback naming the entry, its declared `mountKinds`, and why the mismatch was rejected. 3. **Dev warn** — independently, the `exit` prop wired into a composition-mounted panel is a no-op stub that logs once per exit name in dev. So even if `mountKinds` is omitted on a journey-shaped panel reused in a composition, a panel that calls `exit(...)` still surfaces the silent drop loudly enough to investigate. Backward compatibility: omitting `mountKinds` (the v0.1.0 behavior) is treated as "every surface" — every existing module continues to work in both journeys and compositions without changes. The opt-in is a tightening, not a default shift. The annotation captures _intent_, not _capability_: a module can declare `mountKinds: ["journey", "composition"]` while its component still imports `useJourneyExit` and crashes outside a journey. The dev-warn covers that case; the structural-purity solution (a discriminated `ModuleEntryProps` per mount) is a heavier refactor that the framework intentionally has not taken so panel reuse stays cheap. ### Selectors are pure Selectors run on every state change. They must be pure functions of `(state, deps)` — no I/O, no `setState`, no time-based behavior. The runtime reads the resolution and decides whether to remount the panel (when `module`/`entry` change), keep the existing panel and update `input` (when only `input` changes), or skip rendering (when the resolution is structurally identical to the previous one). `deps` is the shared-dependency snapshot the plugin captures at registry resolve time. It is opaque to the runtime — use it for things like a logger, a feature-flag client, or any service the composition author wants in scope. ### Instance lifecycle and statuses Two statuses: | status | meaning | | ---------- | -------------------------------------------------------------------------------------------------------------- | | `active` | the instance is live; `dispatch`, `subscribe`, and outlet rendering all work normally | | `disposed` | the instance is torn down; subscribers receive one last `"disposed"` snapshot and `getInstance` returns `null` | Disposal happens automatically when: - the last `` for the instance unmounts AND no other `runtime.subscribe()` listeners are attached; - the disposal microtask fires (so React 18/19 StrictMode mount/unmount/mount cycles do not tear an instance down on first visit). Explicit `runtime.end(id, { reason })` short-circuits the auto-disposal and is the right path for programmatic teardown (e.g. a Cmd-K palette killing a stale instance). ### Lifecycle hooks fire order For every instance, the runtime guarantees this sequence: ```text start(handle, input) ├── initialState(input) → state ├── lifecycle.onMount(state, deps) └── options.onMount({ compositionId, instanceId, state }) ... live mutations via dispatch ... end(id) | last outlet detaches ├── status → "disposed" ├── lifecycle.onUnmount(state, deps) ├── options.onUnmount({ compositionId, instanceId, state }) └── definition.onDispose({ compositionId, instanceId, state, reason }) ``` `onError` is fired observation-only on throws from selectors, panel renders, lifecycle hooks, and `onZoneEvent` callbacks. ### Idempotency: `start()` semantics Every `start()` call mints a fresh instance — the runtime does not dedupe by input. Host components should call [`useComposition(handle, input)`](#host-hook--usecomposition) so the id is minted **once per mount** and disposed via the outlet's refcount when the host unmounts. Do not wrap `runtime.start(...)` in `useMemo` (it's not guaranteed to run only once — React may discard or re-evaluate memos during concurrent rendering), and do not call it from `useEffect` (that introduces a render-then-mint lag the outlet can't paper over). For SSR-style hand-off or persistence-driven resume, mint the instance outside React and hand the id to the outlet. ## Authoring patterns ### Hooks vs stores — which to use Both patterns subscribe at slice level (via `useSyncExternalStore` under the hood) and have equivalent re-render behavior — the choice is **ownership**, not performance. | | **Stores** (typed `ReadableStore` / `WritableStore` via `input`) | **Hooks** (`useCompositionState` / `useCompositionDispatch`) | | ------------------------------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------- | | Panel imports | `@modular-react/core` only (for `ReadableStore` / `WritableStore`) | `@modular-react/compositions` + the composition's `TState` type | | Panel workspace deps | Zero on the composition package | Yes — module depends on the composition package for `TState` | | Coupling between panel & composition | Structural — `WritableStore` interface only | Nominal — the composition's `TState` shape | | Reuse in other hosts | Easy — any `WritableStore` works (test mock, shell-level store, …) | Panel only renders inside _this_ composition | | Composition's selector | Calls `stores.writable("key", { get, set })` | Selector returns plain `input`; panel reads from context | | Ceremony at the panel | Two lines of `useSyncExternalStore` per slice | One line of `useCompositionState(s => ...)` per slice | | Best for | Panels owned by a different team than the composition; reusable panels | Panels and composition owned by the same team; one-off internal screens | **Default to stores** when modules and compositions are owned by different teams, or when you want a panel to be reusable outside this composition. **Reach for hooks** when the same team owns both and you want minimum ceremony. The two patterns can coexist in one composition (e.g., the editor panel uses hooks because it's owned by the composition team; integration panels use stores because they're owned by integration teams). ### Pattern — typed store projections (composition-unaware panels) For strict separation between the **composition team** (owns coordination state) and the **panel teams** (own panel modules), project composition state into typed store contracts that panels consume via their `input`. Panels then depend only on the structural store interface — not on the composition's `TState` shape — so they import nothing composition-specific. The framework gives selectors a `stores` factory bound to the active instance. `stores.readable(key, get)` and `stores.writable(key, { get, set })` return objects stable per `(instance, key)`. They wrap the composition's per-instance store with **slice-level change detection** — subscribers fire only when the projected slice value actually differs (`Object.is`). The store types live in `@modular-react/core` as `ReadableStore` / `WritableStore`. They match `useSyncExternalStore`'s contract (`getSnapshot` + `subscribe`); a `WritableStore` adds `set(value)`. **Composition side** — selector projects state into stores: ```typescript import { defineComposition } from "@modular-react/compositions"; import type editorModule from "@myorg/editor"; import type contentfulModule from "@myorg/contentful"; import type strapiModule from "@myorg/strapi"; type Modules = { readonly editor: typeof editorModule; readonly contentful: typeof contentfulModule; readonly strapi: typeof strapiModule; }; interface EditorState { readonly documentId: string; readonly activeSource: "contentful" | "strapi" | null; readonly selectedItem: string | null; } export const editorComposition = defineComposition()({ id: "editor", version: "1.0.0", initialState: (input: { documentId: string }) => ({ documentId: input.documentId, activeSource: null, selectedItem: null, }), zones: { main: { select: ({ state, stores }) => ({ kind: "module-entry", module: "editor", entry: "main", input: { documentId: state.documentId, // Stable WritableStore<"contentful" | "strapi" | null> per // (instance, "activeSource"). The editor panel uses it via // useSyncExternalStore; identity stable across re-renders. activeSource: stores.writable("activeSource", { get: (s) => s.activeSource, set: (value) => ({ activeSource: value }), }), }, }), }, source: { select: ({ state, stores }) => state.activeSource ? { kind: "module-entry", module: state.activeSource, entry: "sourcePanel", input: { documentId: state.documentId, selectedItem: stores.writable("selectedItem", { get: (s) => s.selectedItem, set: (value) => ({ selectedItem: value }), }), }, } : { kind: "empty" }, }, }, }); ``` **Panel side** — module declares the store interface in its `input`; reads via `useSyncExternalStore`: ```typescript import { useSyncExternalStore } from "react"; import { defineEntry, defineModule, schema } from "@modular-react/core"; import type { WritableStore } from "@modular-react/core"; interface SourcePanelInput { readonly documentId: string; readonly selectedItem: WritableStore; } function ContentfulSourcePanel({ input }: { input: SourcePanelInput }) { // Subscribes once per instance — the store's identity is stable, so // useSyncExternalStore doesn't re-subscribe across re-renders. const selectedItem = useSyncExternalStore( input.selectedItem.subscribe, input.selectedItem.getSnapshot, ); return (
    {items.map((it) => (
  • input.selectedItem.set(it.id)} > {it.title}
  • ))}
); } export default defineModule({ id: "contentful", version: "1.0.0", entryPoints: { sourcePanel: defineEntry({ component: ContentfulSourcePanel, input: schema(), }), }, }); ``` The panel imports **nothing composition-specific** — only `@modular-react/core` for the `WritableStore` interface. The composition team can change `EditorState`'s shape without touching the panel; the only contract between them is `WritableStore`. This is the recommended pattern for projects where panel modules and the composition are owned by different teams. For projects where a single team owns both, `useCompositionState` / `useCompositionDispatch` (below) are still supported and slightly less ceremony. ### Pattern — empty zone with a typed message ```typescript zones: { inspector: { select: ({ state }) => state.selectedItemId ? { kind: "module-entry", module: "inspector", entry: "main", input: { id: state.selectedItemId } } : { kind: "empty" }, fallback: () =>

Select an item to inspect.

, }, } ``` ### Pattern — state-driven module dispatch The `module` field of a `module-entry` resolution can be any id in `TModules`. Use this to swap between integrations / variants without authoring N parallel zone descriptors: ```typescript integrationSource: { select: ({ state }) => state.activeIntegrationId ? { kind: "module-entry", module: state.activeIntegrationId, // narrowed to "contentful" | "strapi" by TModules entry: "sourcePanel", input: { documentId: state.documentId }, } : { kind: "empty" }, }, ``` ### Pattern — sibling-zone hand-off via dispatch Panels never call each other directly. To make zone A reflect a change in zone B, B dispatches and A's selector picks up the new state on the next tick. ```typescript // In panel B: const dispatch = useCompositionDispatch();