Share state across your app's frames
Your app can run in several frames at once: the top-level app frame, plus the separate iframes of any modal body or embedded surface it opens. Those frames have separate stores — a plain variable in one is invisible to the others. A state channel lets them share a serializable snapshot, brokered by the shell. The canonical use is a modal driving a live draft that the parent frame reflects behind it.
The model is single-writer, many-readers: a channel has at most one writer (a later publish
displaces the previous one, which goes inert), and readers never write — so there is no lost-update.
State is scoped to one app, keyed by (appId, namespace); it never crosses apps.
Define the channel
Section titled “Define the channel”Declare the channel once and import it from both sides, so the namespace string and the snapshot
shape live in a single place instead of being restated at each call site. For per-record state, wrap
defineStateChannel in a one-line factory keyed by id:
import { defineStateChannel } from "@platform/sdk";
export interface EditDraft { id: string; name: string; email: string;}
// One channel per record id, declared ONCE and imported by both sides. The writer (a modal body) and// every reader (the parent list) build the channel the same way, so they address the same namespace// for the same id — the namespace string is the key, not the channel object's identity.export const editDraft = (id: string) => defineStateChannel<EditDraft>(`editDraft:${id}`);The factory is value-keyed: editDraft(id) returns a fresh channel object each call, but it keys
off the namespace string it produces (editDraft:c_1), never the object’s identity — so a writer’s
editDraft(id) and a reader’s editDraft(id) address the same namespace for the same id.
Write from one frame
Section titled “Write from one frame”The writer claims the single slot with publish() (which seeds the snapshot) and pushes each change
with set():
import { Component, inject, signal } from "@angular/core";import type { StateWriter } from "@platform/sdk";import { SURFACE_CONN } from "../shared/surface.token";import { editDraft, type EditDraft } from "./state-channel";
// The WRITER side: a modal body that publishes its live draft so the parent frame can mirror it.// publish() claims the single writer slot and seeds the snapshot; set() pushes each change. The two// frames have separate stores, so this snapshot is brokered by the shell, not shared memory.@Component({ selector: "customer-edit", template: ` <input [value]="draft().name" (input)="patch('name', value($event))" /> <input [value]="draft().email" (input)="patch('email', value($event))" /> `,})export class CustomerEditWriter { private readonly conn = inject(SURFACE_CONN); protected readonly draft = signal<EditDraft>( (this.conn.params as EditDraft | undefined) ?? { id: "", name: "", email: "" }, ); private readonly writer: StateWriter<EditDraft> = editDraft(this.draft().id).publish( this.conn.state, this.draft(), );
protected patch<K extends keyof EditDraft>(key: K, next: EditDraft[K]): void { this.draft.update((d) => ({ ...d, [key]: next })); // Pushing an unchanged snapshot is safe: the SDK suppresses a structurally-equal set, so you never // need an "already seeded" guard and never flood the other frames with no-op updates. this.writer.set(this.draft()); }
protected value(e: Event): string { return (e.target as HTMLInputElement).value; }}Read from the other frames
Section titled “Read from the other frames”A reader calls subscribe(): it hydrates immediately with the current snapshot (if one exists) and
fires on every later set, returning an unsubscribe. Scope the subscription to the lifetime you care
about — here, the duration of one open edit:
import { Component, OnDestroy, inject, signal } from "@angular/core";import { PLATFORM } from "../shared/platform.token";import { editDraft, type EditDraft } from "./state-channel";
// The READER side: the parent frame mirrors the modal's live draft behind the dialog. subscribe()// hydrates with the current snapshot (if one exists) and fires on every later set; it returns an// unsubscribe. Many readers may observe one channel — only the modal writes, so there is no// lost-update. Here the subscription is scoped to one open edit: subscribe when it opens, unsubscribe// when it closes.@Component({ selector: "customer-row", template: `<b>{{ live()?.name }}</b> <span>{{ live()?.email }}</span>`,})export class CustomerRowReader implements OnDestroy { private readonly platform = inject(PLATFORM); protected readonly live = signal<EditDraft | null>(null); private stop: () => void = () => {};
open(id: string): void { this.stop = editDraft(id).subscribe(this.platform.state, (snapshot) => this.live.set(snapshot)); }
close(): void { this.stop(); this.live.set(null); }
ngOnDestroy(): void { this.stop(); }}See also
Section titled “See also”- Open a shell-brokered modal — the frame whose draft you are usually mirroring.
defineStateChannel,StateChannel,StateClient, andStateWriterin the reference.