Skip to content

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.

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:

state-channel.ts
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.

The writer claims the single slot with publish() (which seeds the snapshot) and pushes each change with set():

customer-edit.ts (modal body)
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;
}
}

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:

customer-row.ts (parent list)
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();
}
}