React Integration
How to use flow-state.dev's React bindings to build interactive UIs.
FlowProvider
Wrap your app (or a section of it) with FlowProvider to set defaults and register renderers:
import { FlowProvider } from "@flow-state-dev/react";
function App() {
return (
<FlowProvider
flowKind="my-app"
userId="devuser"
renderers={{
message: MessageComponent,
reasoning: ReasoningComponent,
component: {
"chart": ChartComponent,
"data-table": DataTableComponent,
},
}}
>
<MyUI />
</FlowProvider>
);
}
Renderer Registry
Renderers map item types to React components:
- Class-based types (
message,reasoning, etc.) — One component each - Parameterized types (
component,container) — Sub-key lookup byitem.component
Nested providers merge renderers (child keys override parent keys).
Connection
FlowProvider also takes baseUrl, apiPath and fetcher, and every hook and component under it calls through one client built from them and userId. A fetcher that adds an Authorization header gives every read and stream that credential:
const fetcher = useMemo(
() => (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
headers.set("authorization", `Bearer ${token}`);
return fetch(input, { ...init, headers });
},
[token],
);
<FlowProvider flowKind="my-app" userId={userId} fetcher={fetcher}>
<MyUI />
</FlowProvider>
Pass a stable reference. A nested provider inherits its parent's. See Client options.
Hooks
useFlow — Session Lifecycle
const flow = useFlow({ autoCreateSession: true });
// Available:
flow.sessions; // Session list
flow.activeSessionId; // Current session ID
flow.createSession(); // Create new session
flow.selectSession(id); // Switch session
useSession — Primary Hook
const session = useSession(flow.activeSessionId, {
items: { visibility: "ui" },
});
// Available:
session.detail; // SessionDetail object
session.latestRequest; // Most recent request (any status), or null
session.items; // All items, including sub-agent items
session.messages; // Message items only
session.blockOutputs; // Block output items only
session.isStreaming; // Active request in progress
session.isLoading; // Initial load in progress
session.error; // Error state
// Identity-based filtering:
session.getItemsByAgent("researcher"); // items stamped with agentName
session.getItemsByVisibility({ history: false }); // items by visibility
// Container-scoped items:
session.getOwnedItems(containerBlockInstanceId);
// Actions:
await session.sendAction("chat", { message: "Hello!" });
await session.abortRequest(); // Stop the in-flight request
await session.resumeLatestRequest(); // Re-run the latest request if it was interrupted/failed
session.refresh(); // Force refetch
session.refresh({ items: false }); // Re-read state only; the items on screen stay
session.followedRequest; // The request the view shows last, even when a later one was sent beside it
Resuming an interrupted request
When a request dies before it can finish — server crash, HMR reload mid-flow, network drop — its record stays as interrupted. latestRequest exposes that, and resumeLatestRequest re-dispatches the same action with the original input, returning a new request id and attaching the stream.
{session.latestRequest?.status === "interrupted" && !session.isStreaming && (
<button onClick={() => session.resumeLatestRequest()}>
Resume
</button>
)}
resumeLatestRequest is a no-op when the latest request is completed, aborted, or in_progress — only interrupted and failed are retryable. The retry re-enters the flow instance that owns the request, read from the record itself, so it lands on the same copy of the flow that ran it even when the provider is bound to another.
Hearing requests you didn't send
A view hears the requests it sends. Some sessions have more than one writer: a conversation with a coordinator, where the workers it hands work to answer, a board several people work, the same conversation open in two tabs. Their lines land in the session, but nothing tells a view that didn't send them. Ask the view to stay live:
const conversation = useSession(sessionId, {
flowKind: "coordinator",
items: true,
live: true,
});
The hook then opens one stream for the session. Each finished item from any request in it joins session.items about a second after the server saves it, in the order a reload would show. The stream carries whole items, not text as it is typed, so another writer's answer appears in one piece. Your own requests' items arrive through their request stream, and none shows twice. Items are told apart by their request and their id together, so two requests that each save an item with the same id both appear in session.items. An item one request emits more than once under the same id, such as a keyed component, shows its latest copy, whatever order the copies arrive in.
The background-work list stays current too. The hook re-reads session.childSessions when a run is dispatched, even one still waiting its turn, and again when the run finishes. To list the runs that haven't finished:
const working = conversation.childSessions.filter((run) => run.status === "active");
A live view holds a connection open while it is mounted, and the server reads the session about once a second for it. Leave live off for a view only one person writes to; the request stream already carries everything there. If the server doesn't offer the stream, or refuses it when the view first connects, the view behaves as if you hadn't asked, with no error. A dropped connection reconnects on its own and fills in what it missed. Access is checked when a connection opens, and the server closes each one after at most 15 minutes, so revoking someone's access to the session takes effect within 15 minutes.
A live view also hears what other sessions change in the scopes it reads: the state and resources of its session, its user and its organization. Someone saves an org setting in another tab, or a worker with no browser attached adds a row to an org collection, and the view reads again. useClientData then shows the new state, a client.derived value over the scope's resources included, and useResourceCollectionList and the other resource hooks read their collection again as they do for the view's own changes:
const session = useSession(sessionId, { flowKind: "desk", live: true });
const { org } = useClientData(session, { org: ["plan"] }); // updates when anyone changes the plan
const licenses = useResourceCollectionList(session, "licenses"); // and when anyone adds a license
On a state change the view reads its state again without its item history, so the cost doesn't grow with the conversation. A change this view made itself, while its request is still streaming, is already on screen and costs no extra read. A change that request makes after its stream has closed, such as from an onFinished hook, is read like anyone else's. A user's own state reaches only that user's views. On a host that pushes, such as serve() from @flow-state-dev/node, the change shows within about a second of being saved. On a host that can't push, such as Vercel or Next.js, the view reads its state every few seconds instead. Keeping a screen current has the details per host.
Once the stream is open, a drop the first reconnect recovers from, such as the server's routine close, shows no change. If that reconnect fails, session.childSessionsStale turns true, and session.items stays as it is, with no error, missing what others write. A connection that gets through clears the flag and fills in what was missed. A reconnect is refused when the session is deleted while the view is open, when the reader's access is revoked, or when a new session takes its id. The flag then stays true and the view stops hearing other writers. If a new session has taken the id and the reader can open it, the next snapshot the view reads, from refresh() for example, moves the view onto that session and clears the flag. Otherwise refresh() sets error and the flag stays.
Reading again when something changes: useSubscription
For a screen that shows a read other writers change, and that isn't a session's own items, hand the hook the topics it shows and the read it already makes:
import { useSubscription } from "@flow-state-dev/react";
import { topic } from "@flow-state-dev/client";
const refresh = useCallback(
() => fsd.flow(flow).read("listTasks_tasks", {}, { sessionId }).then(setTasks),
[sessionId],
);
useSubscription(
[topic.session(sessionId, { resource: "tasks" }), topic.session(sessionId, { resource: "plan" })],
refresh,
{ pollIntervalMs: 3000 },
);
The topics are narrowed to the two boards listTasks_tasks returns (the conversation's tasks and its planning worker's steps), so a reply streaming in the session doesn't wake the read; a task that settles in another session still does (see Task boards).
On the Node host the read runs as soon as the change is saved, over a WebSocket; on a host with no push it runs every pollIntervalMs. A burst of changes runs it once more after the read in flight finishes, not once per change. The read gets the notice that woke it ({ topic, kind, origin? }), or null when the screen should read everything again: after a connect or a reconnect, on a timer tick, and when push starts or stops reaching every server process. Act on kind when the screen only cares about some changes, and read everything on null. Leave pollIntervalMs out for no timer. enabled: false subscribes nothing.
The hook doesn't read on mount; keep the first read where the screen already makes it. It uses the provider's client, so the provider's fetcher signs the socket in. An app that builds its own client passes it as client instead. See fsd.subscribe for which topics a caller hears.
Background work
Some flows dispatch a long job to run on its own. The conversation returns straight away and the job carries on in a session of its own, called a dispatch run. session.childSessions is how you show those runs, one ChildSessionSummary per run.
Each entry carries enough to render a row:
const session = useSession(sessionId, { flowKind: "assistant" });
return (
<aside>
{session.childSessions.map((run) => (
<button key={run.id} onClick={() => setOpenRun(run)}>
{run.topic ?? run.id} — {run.status ?? "not started"}
</button>
))}
</aside>
);
topic is the key the flow derived the run's session from, so it reads well only when the flow keyed on something legible. Falling back to the session id keeps the row honest when it didn't.
This list sits beside the conversation rather than inside it. Nothing a run produces is added to the chat for you, so if you want "here's what came back" to appear in the transcript, write that yourself and word it how you like.
When the list changes
The list is current as of the last thing the reader did. It is re-read when the component mounts, at the start of each action you send, and whenever you call session.refresh(). By default it does not update on its own while someone sits and watches, so work started in another tab shows up on their next action — or immediately, if you give them a refresh control.
In a view with live: true the list also changes on its own, within about a second of a run being dispatched (even one waiting its turn) or finishing, from this tab or anywhere else. A run that hasn't finished stays in the list even when it is older than the page the hook reads.
session.childSessionsStale turns true for different reasons. A re-read failed, and the next successful read clears the flag. Or you asked for a page the server won't serve, and nothing but a smaller limit clears it. In a live view it also turns true when the stream stops following the session, as above, and after a refused reconnect a retry clears it only when a new session the reader can open has taken the id. In every case the rows already fetched stay on screen; the hook never empties the list. The hook doesn't say which reason applies, so mark the panel as possibly out of date rather than showing nothing, and if you offer a retry, don't promise it clears the flag.
The list holds 25 rows by default, newest first. Ask for a different page size with childSessions: { limit }. The server caps that value, at 100 unless the host raises it, and answers a larger one with a 400, which is the second cause above:
const session = useSession(sessionId, {
flowKind: "assistant",
childSessions: { limit: 50 },
});
What a row's status tells you
status is missing until a run has been dispatched into the row's session. From then on it reads "active" until the work ends, even while the run waits its turn, and after that it says how the work ended.
"active" means not finished and nothing else. It does not tell you whether the job is thinking, queued, or stopped waiting for someone to answer a question. It also reports the last state recorded, not a check that the job is alive: work whose worker stopped unexpectedly reads as unfinished until the system picks it back up. So in a list someone reads once, don't label an "active" row "running" or "working". In a live view a row stops reading "active" within about a second of the work ending, so "working" is a usable label there, as long as you accept that a job paused for approval, or one whose worker died, shows the same way. What status tells you has each value in full.
New status values can appear over time. Render one you don't recognise instead of switching exhaustively over the set.
Opening one
A run is a session, so the hook you already have reads it. Keep the whole row when one is chosen, not just its id, and mount the detail view with it:
function BackgroundJobDetail({ run, flowKind }: { run: ChildSessionSummary; flowKind: string }) {
// `autoResume` matters here: without it you load one snapshot and it never
// fills in while the job keeps going.
const job = useSession(run.id, {
flowKind: run.flowId ?? flowKind, // the run's flow, not the conversation's
autoResume: true,
});
return <ItemsRenderer items={job.items} />;
}
Pass the flow the run itself belongs to, which is not always the conversation's. A run dispatched on the same flow stays there, so the conversation's kind is the value. A run dispatched into another flow belongs to the instance it was sent to, and the row's flowId is that address. run.flowId ?? flowKind covers both, since flowId is absent only on a row that records no owner. Passing a different name reads as a different flow, and an active run's stream comes back 404 with the view stuck on its first snapshot.
Steps show up as the job finishes them. You won't see text being typed out as it is generated — background work surfaces completed steps only.
useClientData — Client Data
const clientData = useClientData(session, {
session: ["activePlan", "messageCount"],
user: ["preferences"],
});
// Typed mode with schema validation:
const clientData = useClientData(session, {
session: {
activePlan: activePlanSchema,
},
});
Live updates
Values update mid-stream as state_change items arrive over SSE — components re-render within the same paint as the corresponding ctx.<scope>.patchState(...) call, not only after the request terminates. This applies to keys declared in the flow's client.<scope>.expose config (the "expose half" of clientData). Derived projections (declared in client.<scope>.derived) refresh once at terminal request status, since the framework can't recompute their projection functions client-side.
Two limitations to know about:
- Mid-stream updates only apply to expose keys whose initial value is already present in the snapshot. If a flow exposes
foobutstate.fooisundefinedat session start, the firstpatchState({ foo: ... })won't surface until terminal refresh. Declare a default in the scope'sstateSchemaif that case matters. atomicStatemutations don't carry a structured delta on the wire, so values affected by an atomic write also stay stale until terminal refresh.
See state_change items for the wire format and State and scopes for the scope model.
useAction — Low-Level
For direct action execution without session management:
const { execute, loading, error } = useAction({
flowKind: "my-app",
action: "chat",
userId: "devuser",
});
useSubmit — Buttons That Change Something
For a button whose call may be slow or refused (pin, resolve, create), useSubmit holds it while the call runs and keeps the refusal to show beside it:
const resolve = useSubmit(() => api.resolve(taskId));
<button disabled={resolve.busy} onClick={() => void resolve.submit(undefined)}>Resolve</button>;
{resolve.error ? <p role="alert">Couldn't resolve: {resolve.error}</p> : null}
A second click while the first call runs does nothing, and a failed call never reads as done. It takes any async function; useAction is the one tied to a flow action.
useRequestStream — Direct Stream Access
const { items, status, isStreaming } = useRequestStream({
source: { requestId },
filter: { itemTypes: ["message", "component"] },
});
The source is either { requestId } (opens a GET-by-id stream) or { response } (consumes a pre-fetched POST stream). Message and reasoning text accumulates token-by-token as it streams.
Rendering Items
ItemRenderer and ItemsRenderer
import { ItemRenderer, ItemsRenderer } from "@flow-state-dev/react";
// Render all items. Sub-agent items are filtered out of the default
// conversation view — pass `showSubAgents` to surface them inline.
<ItemsRenderer items={session.items} />
// Show sub-agent items in the main stream (e.g., for a debug view).
<ItemsRenderer items={session.items} showSubAgents />
// Render a single item
<ItemRenderer item={item} />
Custom Renderers
All custom renderers receive { item } as their prop:
import type { MessageItem } from "@flow-state-dev/core/items";
function ChatMessage({ item }: { item: MessageItem }) {
return (
<div className={item.role === "user" ? "user-msg" : "assistant-msg"}>
{item.content.map((part, i) => (
<span key={i}>{part.text}</span>
))}
</div>
);
}
Suppressing Built-in Renderers
Pass false to suppress a type:
const renderers = {
status: false, // Don't render status items
reasoning: false, // Don't render reasoning items
};
Resource collection hooks
Three hooks for working with collection resources — list views, single-item lookups, and an underlying primitive. All three depend on the collection's client.state.read permission for per-item state access.
useResourceCollection
The underlying primitive. Returns list, get, query, actions, refetch, plus prefetched and count from the snapshot. Use it directly when you need control over fetching beyond what the convenience hooks below offer.
const { list, get, actions, refetch, count } = useResourceCollection(session, "artifacts");
await list({ limit: 20, topicPrefix: "artifacts/projects/" });
const item = await get("artifacts/spec.md");
await actions.create({ topic: "new.md", content: "# New" });
Pages are cached per hook instance, keyed by the normalized query. Resource changes observed in the session stream invalidate the cache for the affected collection so the next call refetches automatically.
useResourceCollectionList
The common case: paginated list view with loadMore.
const { items, pagination, isLoading, error, loadMore, refetch } =
useResourceCollectionList(session, "artifacts", { limit: 50 });
return (
<>
{items.map((item) => (
<ArtifactRow key={item.topic} item={item} />
))}
{pagination?.hasMore && <button onClick={loadMore}>Load more</button>}
</>
);
Each item is a CollectionItemHandle<TClient> — { topic, clientData, fetchContent() }. fetchContent() calls the existing content endpoint for that topic on demand.
If the collection declares prefetchWindow > 0, the snapshot's prefetched window paints first while the network fetch runs underneath.
useResourceCollectionItem
For a single item by topic. Returns null if the topic isn't present.
const { item, isLoading, error, refetch } = useResourceCollectionItem(session, "artifacts", "spec.md");
if (item === null) return <div>Not found</div>;
const content = await item.fetchContent();
If the collection declares client: { live: true }, mutations to this topic update item.clientData mid-stream with no refetch — a memo flipping from writing to published repaints in place. useResource behaves the same way for single resources, and useResourceCollectionList applies the overlay across every item (status updates, removals on delete, and mid-stream creates) so an all-items navigator stays live. See Resources: Client Access — Live updates for the server-side setup.
Typing clientData
These hooks (and useResource) take a TClient type parameter that types clientData instead of leaving it unknown. Derive it from the definition with ClientDataOf<typeof collection> so it can't drift from the projection:
import type { ClientDataOf } from "@flow-state-dev/core";
import { artifacts } from "./resources";
const { item } = useResourceCollectionItem<ClientDataOf<typeof artifacts>>(session, "artifacts", "spec.md");
// item?.clientData is the projected type — no cast
The parameter defaults to unknown, so untyped call sites are unchanged. See Client Access — Typing clientData for how the type is derived from expose / exclude / data.
useResourceManifest
The manifest tells you what every public resource on the session exposes — kind, scope, pattern, declared permissions. The hook fetches once per flowKind and shares the result across all components via a module-level cache.
const { manifest, isLoading, error } = useResourceManifest(session);
const canCreateArtifact = manifest?.resources
.find((r) => r.ref === "artifacts")
?.client.content?.create === true;
See Resource Manifest for the full reference.
Suspensions and approvals
When a durable flow calls ctx.suspend(), execution pauses at that step and a suspension item arrives on the session's SSE stream. The flow waits for an external actor to resolve it before continuing.
useSuspensions derives pending and resolved suspensions from session.items. It pairs each suspension item with its later suspension_resume item by suspensionId and exposes approve and reject callbacks.
approve and reject stream the resume: the resumed continuation streams straight back into session.items, so the post-resume output renders live without a page refresh. This matters most on serverless, where the continuation runs in a different invocation than any open stream — streaming the resume keeps it on the same request the client is watching. The callbacks resolve to void once the resume is dispatched; the result shows up as new items.
useSuspensions
import { useSuspensions } from "@flow-state-dev/react";
function ApprovalPanel({ session }) {
const { pending, approve, reject, error } = useSuspensions(session);
return (
<>
{pending.map(({ item }) => (
<div key={item.suspensionId}>
<p>{item.message}</p>
<button onClick={() => approve(item.suspensionId)}>Approve</button>
<button onClick={() => reject(item.suspensionId)}>Reject</button>
</div>
))}
{error && <p style={{ color: "red" }}>{error.message}</p>}
</>
);
}
pending is a live slice of suspensions — it updates automatically when a suspension_resume item arrives on the stream. approve and reject accept an optional data payload that ctx.suspend() returns on the resumed step.
Filter to a specific reason or request:
const { pending } = useSuspensions(session, {
reasons: ["user_approval"],
requestId: "req_abc",
});
Rendering approvals inline
By default ItemRenderer renders a built-in approval card (ApprovalRenderer) for type === "suspension" items. Set flowKind on <FlowProvider> so the card can call the resume endpoint:
<FlowProvider flowKind="my-flow">
<ItemsRenderer items={session.items} />
</FlowProvider>
When a suspension item reaches ItemRenderer, the card shows item.message, Approve and Reject buttons, and optional item.data in a collapsible details section.
With just flowKind set, the inline card resolves with a non-streaming resume — it works, but the continuation's output only shows after the session refetches. To make the inline card stream like useSuspensions does, wrap the item list in <SuspensionResolverProvider> and hand it the session's resumeSuspension:
import { SuspensionResolverProvider } from "@flow-state-dev/react";
<FlowProvider flowKind="my-flow">
<SuspensionResolverProvider resolve={session.resumeSuspension}>
<ItemsRenderer items={session.items} />
</SuspensionResolverProvider>
</FlowProvider>
Now approving or rejecting from the inline card streams the continuation back into session.items — the chat view updates in place. session.resumeSuspension is the same streaming resume useSuspensions calls under the hood, so the two paths behave identically.
Beyond approval: questions, forms, and selections
A suspension doesn't have to be a yes/no gate. The same pause-and-resume machinery can ask for a free-text answer, a small form, or a choice from a fixed set. The two new resolution actions are submit (the human hands back a typed payload) and skip (the human declines an optional step). Approve and reject keep their original binary meaning.
Which card renders is decided by the suspension's reason and the shape of its resumeSchema:
reason: "user_approval"renders the Approve / Reject card.reason: "user_input"picks by schema shape: a free-text question when there's no flat schema, a selection when the schema is a single enum (or array of enums), and a form when it's a flat object of scalars and enums.
<ItemsRenderer> makes that choice automatically, so a user_input suspension renders an actionable card with no extra wiring. A registered renderers.suspension still wins, and renderers.suspension: false still suppresses inline cards.
On the server, a user_input gate defaults to allow: ["submit"]. Add "skip" to make the step optional. When a human skips, ctx.suspend() returns the SUSPENSION_SKIPPED sentinel instead of throwing, so the flow author branches on it and falls back to a default.
import { handler, SUSPENSION_SKIPPED } from "@flow-state-dev/core";
import { z } from "zod";
const clarify = handler({
name: "clarify",
inputSchema: z.object({ draft: z.string() }),
outputSchema: z.object({ priority: z.string() }),
execute: async (input, ctx) => {
const answer = await ctx.suspend!({
reason: "user_input",
message: "How should we triage this draft?",
resumeSchema: z.object({
priority: z.enum(["low", "normal", "urgent"]),
note: z.string(),
}),
allow: ["submit", "skip"],
});
if (answer === SUSPENSION_SKIPPED) return { priority: "normal" };
return answer as { priority: string };
},
});
// Client: ItemsRenderer auto-picks the form card for this user_input gate.
<SuspensionResolverProvider resolve={session.resumeSuspension}>
<ItemsRenderer items={session.items} />
</SuspensionResolverProvider>
To build your own input, useSuspensionForm(item) is the headless controller. It derives form fields from the resumeSchema, holds the in-progress value, validates it client-side against that schema, and resolves via submit or skip. It returns value / setValue / setField, the derived fields and options, path-keyed errors, canSubmit / canSkip, and the submit / skip callbacks. The three default cards (QuestionRenderer, SelectionRenderer, SchemaFormRenderer) are all thin views over it.
The default cards only auto-generate for a flat object of scalars (string / number / boolean) and enums; nested objects, arrays of objects, and unions need an author-supplied component named via the suspension's render.component hint.
See Durable execution for the server-side allow and SUSPENSION_SKIPPED contract, and the React API reference for the full useSuspensionForm signature.
Custom approval UI
Register a component under the suspension slot to replace the default card:
<FlowProvider renderers={{ suspension: MyApprovalCard }}>
The component receives { item } where item is the SuspensionItem. Use useSuspensions from a parent to manage resolution and pass onApprove/onReject handlers down.
For a fully custom layout — modals, sidebars, toasts — suppress inline rendering and manage the list yourself:
<FlowProvider renderers={{ suspension: false }}>
{/* ItemsRenderer shows no inline cards */}
<ItemsRenderer items={session.items} />
{/* Your own layout reads from useSuspensions */}
<ApprovalSidebar pending={pending} onApprove={approve} onReject={reject} />
</FlowProvider>
For a full server-to-UI walkthrough (defining the gate, the useApproval hook, and the Approval card), see the Human-in-the-Loop guide. See Durable execution for the server-side setup (ctx.suspend(), SuspendOptions, the resume endpoint).
See the API reference for the full useSuspensions signature.
Typical Pattern
function ChatUI() {
const flow = useFlow({ autoCreateSession: true });
const session = useSession(flow.activeSessionId);
return (
<div>
{/* Render items */}
{session.items.map((item) => (
<ItemRenderer key={item.id} item={item} />
))}
{/* Input form */}
<form onSubmit={(e) => {
e.preventDefault();
const msg = new FormData(e.currentTarget).get("message") as string;
session.sendAction("chat", { message: msg });
e.currentTarget.reset();
}}>
<input name="message" />
<button disabled={session.isStreaming}>Send</button>
</form>
</div>
);
}