Skip to main content

React API

@flow-state-dev/react — React hooks, renderers, and context providers.

Peer dependency: react ^18.0.0 || ^19.0.0

FlowProvider​

import { FlowProvider } from "@flow-state-dev/react";

<FlowProvider
flowKind="my-app"
sessionId="optional-initial-session"
userId="devuser"
renderers={{
message: MessageComponent,
reasoning: ReasoningComponent,
component: {
"chart": ChartComponent,
},
}}
>
{children}
</FlowProvider>

Nested providers merge renderers (child keys override parent keys).

The hooks and components under a provider share one client, built from its userId, baseUrl, apiPath and fetcher. Pass fetcher to add credentials to every call and stream, or pass the engine's createInProcessFetch(flowState) to reach an engine in the same process. A nested provider inherits its parent's. Pass a stable reference. Client options lists every prop.

Hooks​

useFlow(options?)​

Session lifecycle management.

// flowKind and userId come from the FlowProvider above, or pass them here.
const flow = useFlow({ autoCreateSession: true });

flow.sessions; // SessionSummary[]
flow.activeSessionId; // string | undefined
flow.createSession(); // Promise<SessionDetail>
flow.selectSession(id); // void; pass undefined to clear the selection

createSession and autoCreateSession send no initial state. On a flow whose sessions need some, such as a required readonly field, create the session with the client's fsd.flow(address).createSession and pass its id to selectSession. See Example: one session per project.

useSession(sessionId, options?)​

Primary hook for session data and actions.

const session = useSession(sessionId, {
items: true, // default
items: false, // skip items
items: { visibility: "ui" }, // filter by visibility
items: { includeTransient: false }, // exclude transient items
live: true, // also hear requests this view didn't send (default false)
});

session.detail; // SessionDetail | null
session.snapshot; // SessionStateSnapshotResponse | null
session.latestRequest; // SessionRequestSummary | null — most recent request (any status)
session.followedRequest; // { id, status, startedAtMs?, completedAtMs? } | null — the request the view shows last
session.items; // OutputItem[] — includes sub-agent items
session.messages; // MessageItem[]
session.blockOutputs; // BlockTraceItem[]
session.functionCalls; // FunctionCallItem[]
session.isLoading; // boolean
session.isStreaming; // boolean
session.error; // Error | null

// Dispatch runs started from this session (one entry per run):
session.childSessions; // readonly ChildSessionSummary[]
session.childSessionsStale; // boolean — the list may be short; rows are kept

// Identity-based filtering:
session.getItemsByAgent("researcher"); // items stamped with agentName
session.getItemsByVisibility({ history: false }); // items by visibility

// Container-scoped items:
session.getOwnedItems(containerBlockInstanceId);

await session.sendAction("chat", { message: "Hello!" });
await session.abortRequest(); // signal in-flight request to stop
await session.resumeLatestRequest(); // re-dispatch latest if interrupted/failed
await session.resumeSuspension({ // approve/reject a suspension, stream the continuation
suspensionId: "susp_1",
requestId: "req_1",
action: "approve",
});
session.refresh(); // snapshot with its whole history, and background work
session.refresh({ items: false }); // state, detail and background work; no item pages

childSessions lists the dispatch runs started from this session: work that outlives the turn and runs in a session of its own. Each entry is a ChildSessionSummary. The list sits apart from items, and nothing a run produces is folded into the conversation.

It is current as of the reader's last interaction: re-read on mount, at the start of each action, and on refresh(). With live: true it is also re-read when a run is dispatched, even one still waiting its turn, and again when the run finishes. Without live, nothing updates it while they wait. A row's status is absent until a run has been dispatched into its session, and "active" means only not finished. See What status tells you.

live defaults to false. When true, the hook also hears requests it didn't send and keeps childSessions current as runs are dispatched and finish. It also reads again when another session changes the state or a resource of the session, its user or its organization, so useClientData and the resource hooks show it. See Hearing requests you didn't send.

The list holds 25 rows by default; pass childSessions: { limit } in the hook's options for a different page size. childSessionsStale turns true on a failed re-read, cleared by the next successful one, and on a limit above the server's cap, cleared only by asking for a page that fits. With live: true it also turns true when the session stream stops following the session after it opened: from the first failed reconnect until a connection gets through, and after a refused reconnect until a snapshot read, such as refresh(), finds a new session under the id that the reader can open. That read moves the view onto the new session. The rows already read stay in every case — the hook never empties the list. Background work walks through rendering the panel.

resumeLatestRequest is a no-op unless latestRequest.status is interrupted or failed. The server creates a new request that re-runs the original action with the same input, and the hook auto-attaches to its stream. The re-run goes through the flow instance recorded as the request's owner (latestRequest.flowId), not the provider's flowKind, so a session started through one copy of a flow stays with it; continueRequest re-enters the same way.

resumeSuspension resolves a pending durable-execution suspension and streams the resumed continuation back into session.items, so the resolution renders live (no refresh) even on serverless. requestId is the suspended request's id (carried on the suspension item); the continuation re-enters that same id. This is the streaming resume useSuspensions and <SuspensionResolverProvider> build on.

The hook reads a long history a page at a time and starts over when the pages shift under it, as when a keyed component is emitted again partway through the read. If they still shift after five tries, it sets error rather than show part of the history. A snapshot read started after the failure, such as refresh(), clears that error when it succeeds, as it clears any error a failed snapshot read left. It leaves an action's error alone.

useClientData(session, options)​

Read client data values from session state snapshot.

// String array mode — subscribe by name
const data = useClientData(session, {
session: ["activePlan", "messageCount"],
user: ["preferences"],
});

// Schema mode — subscribe with type inference
const data = useClientData(session, {
session: {
activePlan: activePlanSchema,
},
});

useAction(options)​

Low-level action execution.

const { execute, loading, error } = useAction({
flowKind: "my-app",
action: "chat",
userId: "devuser",
});

await execute({ message: "Hello!" });

useSubmit(action)​

Holds a button or form while an async call runs, and keeps the refusal to show instead of throwing.

const pin = useSubmit(() => api.pin(taskId));

<button disabled={pin.busy} onClick={() => void pin.submit(undefined)}>Pin</button>;
{pin.error ? <p role="alert">Couldn't pin: {pin.error}</p> : null}
  • submit(event?, ...args) runs action(...args). It prevents a form event's default and never throws. A call made while one is running does nothing, so a double click sends once.
  • busy is true while the action runs.
  • error is the last refusal's message. It clears when the action runs again.

It wraps any async call. To send a flow action, use useAction.

useRequestStream(options)​

Direct request-stream access. Message and reasoning text streams in token-by-token.

const { items, status, isStreaming } = useRequestStream({
source: { requestId },
filter: { itemTypes: ["message", "component"] },
});

Use source: { response } to consume a pre-fetched POST stream (inline streaming) instead of opening a separate GET-by-id connection.

useSubscription(topics, read, options?)​

Runs read again when something under topics changes, through the provider's client (or options.client) and fsd.subscribe.

useSubscription([topic.session(sessionId, { resource: "tasks" })], refresh, { pollIntervalMs: 3000 });
ArgumentTypeNotes
topicsWireTopic[]From topic.session(id), topic.user(), topic.org(). A new array with the same topics keeps the subscription.
read(notice: WireNotice | null, signal: AbortSignal) => unknownThe screen's read. One runs at a time, and at most one more waits. Gets the notice that woke it, 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. It also gets null when several changes arrived during one read (one follow-up read covers them all).
read's signalAbortSignalAborted when the subscription ends: the screen unmounts, or its topics or options change. A read that awaits checks signal.aborted before it sets state or starts more work, since a read already running when the screen goes away still finishes.
options.pollIntervalMsnumberRead at this pace wherever push can miss a change. Omitted, no timer.
options.enabledbooleanfalse subscribes nothing. Default true.
options.clientPick<Client, "subscribe">Your own client, when you build one with its own fetcher, so the socket signs in the same way. Pass a stable reference. Default: the provider's client.

Returns nothing. It doesn't read on mount.

useSessionList(options)​

A flow's sessions, kept current. Use it to draw your own list of conversations, with each one's state.

import { useSessionList, sessionLabel, sessionRowState } from "@flow-state-dev/react";

function Conversations({ onPick }: { onPick: (id: string) => void }) {
const { sessions, isLoading, error, refresh } = useSessionList({ flowKind: "support" });

if (error) return <button onClick={refresh}>Retry: {error}</button>;
if (isLoading && sessions.length === 0) return <p>Loading…</p>;

return (
<ul>
{sessions.map((session) => (
<li key={session.id}>
<button onClick={() => onPick(session.id)}>{sessionLabel(session)}</button>
{sessionRowState(session)?.label}
</li>
))}
</ul>
);
}
OptionTypeNotes
flowKindstringEvery session of a flow kind.
flowIdstringOne copy of a collection flow, by its exact id.
includeDispatchRunsbooleanAlso list the sessions dispatchers ran work in. Default false.
includeStatusbooleanAsk for each row's requestStatus. Default true.
enabledbooleanfalse reads nothing and holds nothing. Default true.
clientSessionListClientYour own client. It needs sessions.list, and subscribe to stay current. Pass a stable reference. Default: the provider's client.

Returns { sessions, isLoading, error, refresh }. sessions is a SessionSummary[], newest first; error is a message string or null; refresh() reads again now.

It reads on mount, again on each "run" notice on topic.user(), and again when the page becomes visible. A session created or changed in another tab, a script or a CLI run shows up without a reload. A change from another server process arrives at once only on a bus that crosses processes; otherwise it shows up at the next read, such as when the page next becomes visible. A client without subscribe reads once. The rows are the ones sessions.list returns.

sessionLabel(session), sessionRowState(session) and sessionAge(updatedAt, now) are the helpers FlowNavigator draws its rows with, so your list can say the same things. sessionLabel gives the title, or a shortened id. sessionRowState gives { status, label, mark, needsYou } or null; see the navigator's session rows for which states it covers. sessionAge gives now, 4m, 3h, 2d or 5w.

useJumpToLatest(options?)​

Scrolls a conversation the way ChatGPT and Claude do. When the person sends a line, the line rises to near the top of the view and the reply streams in under it, where they can read it. After that the view stays where it is until the reader moves it.

const feed = useJumpToLatest({ resetKey: sessionId });

return (
<>
<div ref={feed.ref} className="overflow-y-auto">
{lines.map((line) => (
<div key={line.id} {...(line.role === "user" ? feed.mine(line.id) : {})}>{line.text}</div>
))}
</div>
{feed.atEnd ? null : <button onClick={feed.jump}>Jump to latest</button>}
<Composer onSend={(text) => { feed.sent(); return send(text); }} />
</>
);
Option / returnTypeNotes
options.resetKeystring | nullWhat the feed shows, such as a session id. A change to a different conversation opens the feed at its end again, with no animation (with startAtEnd on), and with either setting drops the space kept for the last conversation and cancels a rise that hasn't happened yet. Replacing the scrolling element under the same key leaves the reader where they were. The one exception is a send that creates the conversation: when resetKey goes from null to a key after sent(), the feed doesn't reopen and keeps its space, a line that hasn't risen yet rises when it lands, and one that already rose stays where it is.
options.startAtEndbooleanOpen at the end. Default true; false opens where the element starts, for a feed with a lead above its turns, and a resetKey change doesn't reopen such a feed at its end.
ref(node: HTMLElement | null) => voidAttach to the scrolling element.
mine(key)(key: string) => SentLineMarkReturns { "data-sent-line": key }. Spread it on each of the person's own lines. key should stay the same while the line redraws, such as its id. Put it on an element with a box of its own, not a display: contents wrapper.
sent()() => voidCall when the person sends. Their marked line scrolls to near the top when it lands, once. Calling it after the line is drawn works too, for an app that learns of the send in an effect: the newest marked line the feed had not shown before is the one that rises. Called before the scrolling element mounts, such as from a view that shows an empty state until the first line, the line still rises when the feed mounts with it, with space kept below it for the reply.
atEndbooleanfalse when there is more than 32 px of content below the view: show a jump-to-latest button.
jump()() => voidScroll to the end of the content, smoothly.

To let the line reach the top under a short reply, the hook adds space at the end of the feed as bottom padding. The space shrinks as the reply grows and is gone once the reply fills the view. Otherwise it stays until the next send, or until resetKey switches to a different conversation (with either startAtEnd setting), so a short reply has blank space under it. atEnd and jump() ignore it.

A reply that grows past the bottom edge, or content that isn't the person's line, never moves the view. A feed that never calls mine or sent keeps the view where the reader put it and shows the button when there is more below.

Any scroll the reader makes, with the wheel, touch, scroll keys (arrows, Page Up/Down, Home, End, Space) or the scrollbar, cancels a rise that hasn't happened yet. Keys typed into an input, textarea, select or editable element inside the feed don't count as scrolling.

With startAtEnd on, a feed that opens is already at its end on the first frame, with nothing animating: on mount, when resetKey changes, and when content first fills a feed that was empty, such as a conversation that loads after the feed mounts.

useVoice(session, options)​

Voice input/output composing with useSession.

import { useVoice } from "@flow-state-dev/react";

const voice = useVoice(session, {
action: "run",
buildInput: (transcript) => ({ message: transcript }),
});

voice.isListening; // boolean — mic is recording
voice.isSpeaking; // boolean — audio playback active
voice.isProcessing; // boolean — server transcribing
voice.interimTranscript; // string — browser speech recognition (interim)

voice.startListening(); // start recording
voice.stopListening(); // stop recording, transcribe, send action
voice.stopSpeaking(); // stop audio playback

See the Voice guide for full usage details.

Renderers​

ItemRenderer​

Render a single item using the registered renderer.

import { ItemRenderer } from "@flow-state-dev/react";

<ItemRenderer item={item} />

ItemsRenderer​

Render a list of items. By default, conversational items with history: false visibility (sub-agent output) are filtered out — they're available in session.items but hidden from the default conversation view so orchestrator chatter doesn't crowd the UI. Pass showSubAgents to surface them inline, or render a per-agent view via session.getItemsByAgent(name).

import { ItemsRenderer } from "@flow-state-dev/react";

// Default — filters sub-agent items
<ItemsRenderer items={session.items} />

// Opt in — show sub-agent items inline
<ItemsRenderer items={session.items} showSubAgents />

Custom Renderers​

import type { MessageItem } from "@flow-state-dev/core/items";

function ChatMessage({ item }: { item: MessageItem }) {
return <p>{item.role}: {item.content[0]?.text}</p>;
}

// Register in FlowProvider
<FlowProvider renderers={{ message: ChatMessage }}>

// Suppress a type
<FlowProvider renderers={{ status: false }}>

RendererRegistry​

Type for the renderers map:

type RendererRegistry = {
message?: ComponentType<{ item: MessageItem }> | false;
reasoning?: ComponentType<{ item: ReasoningItem }> | false;
suspension?: ComponentType<{ item: SuspensionItem }> | false;
component?: Record<string, ComponentType<{ item: ComponentItem }>>;
container?: Record<string, ComponentType<{ item: ContainerItem }>>;
// ... other item types
};

Pass false for any slot to suppress its built-in fallback renderer.

SuspensionResolverProvider​

Bridges a session's streaming resume to the inline default <ApprovalRenderer>. Wrap the subtree that renders session.items and pass session.resumeSuspension; the inline card then streams the continuation into the chat view instead of doing a non-streaming resume. Without it, the card still resolves — just without live output until the session refetches.

import { SuspensionResolverProvider } from "@flow-state-dev/react";

<SuspensionResolverProvider resolve={session.resumeSuspension}>
<ItemsRenderer items={session.items} />
</SuspensionResolverProvider>

Workforce components​

FlowNavigator, Roster, BoardColumns, BoardList and WorkerDetail draw a workforce: a sidebar over your flows, instances and sessions, the workers hired in an organization, a task board as columns or as a list, and one worker's details. Workforce components covers the navigator, the roster and the boards: what each one reads, and how to style it. Roster and BoardColumns take live, to read again when another session changes their collection. WorkerDetail is covered in the package README.

Suspensions​

useSuspensions(session, options?)​

Derives pending and resolved suspensions from session.items. Pairs each suspension item with its suspension_resume item by suspensionId. approve/reject stream the resumed continuation back into session.items (via session.resumeSuspension), so the resolution renders live.

const {
suspensions, // SuspensionView[] — all suspensions matching options
pending, // SuspensionView[] — subset where pending === true
resolve, // (id: string, { action, data }) => Promise<void> — general resolver
approve, // (suspensionId: string, data?: unknown) => Promise<void>
reject, // (suspensionId: string, data?: unknown) => Promise<void>
error, // Error | null — most recent failed resolve call
} = useSuspensions(session, {
requestId: "req_abc", // optional: restrict to one request
reasons: ["user_approval"], // optional: restrict by reason
});

resolve(id, { action, data }) is the general resolver: action is "approve" | "reject" | "submit" | "skip", and submit carries the typed payload in data. approve(id, data) and reject(id, data) are thin wrappers over it.

Each SuspensionView has:

interface SuspensionView {
item: SuspensionItem;
status: SuspensionStatus; // "pending" | "approved" | "rejected" | "submitted" | "skipped" | "timed_out" | "expired" | "stopped"
pending: boolean;
resumeData?: unknown;
resolvedBy?: string;
allow: ResumeAction[]; // permitted actions, e.g. which controls to show
isResolving: boolean; // true while a resolve is in flight for this suspension
}

resolve, approve, and reject rethrow on failure so callers can branch on the error. The last failure is also captured in error.

useSuspensionForm(item, options?)​

Headless controller for the non-binary input shapes — a clarifying question, a flat form, or a single/multi selection. Where useApproval drives the binary gate, this drives the submit / skip path. It derives form fields from the suspension's resumeSchema (bounded to a flat object of scalars and enums, or a single top-level scalar/enum), holds the in-progress value, validates it client-side, coerces numbers, and resolves through the same streaming transport.

import { useSuspensionForm } from "@flow-state-dev/react";

function ClarifyCard({ item }) {
const f = useSuspensionForm(item);
if (f.resolved) return <span>{f.outcome.icon} {f.outcome.label}</span>;
return (
<div>
{f.fields.map((field) => (
<label key={field.key}>
{field.label}
<input
value={String(f.value[field.key] ?? "")}
onChange={(e) => f.setField(field.key, e.target.value)}
/>
{f.errors[field.key] && <em>{f.errors[field.key]}</em>}
</label>
))}
<button disabled={!f.canSubmit} onClick={f.submit}>Submit</button>
{f.canSkip && <button onClick={f.skip}>Skip</button>}
</div>
);
}

Return shape:

interface UseSuspensionFormResult {
kind: SuspensionValueKind; // "object" | "string" | "number" | "boolean" | "enum" | "enum-multi"
value: unknown; // object for kind:"object", else a scalar/array
setValue: (next: unknown) => void;
setField: (key: string, next: unknown) => void; // set one property of an object value
fields: SchemaField[]; // derived from a flat object schema (empty for scalar kinds)
options?: string[]; // for a top-level enum / enum-multi
errors: Record<string, string>; // path-keyed ("value" for a scalar)
canSubmit: boolean;
canSkip: boolean; // true when the suspension permits "skip"
submit: () => Promise<void>; // validate, then resolve with action:"submit"
skip: () => Promise<void>; // resolve with action:"skip" (no payload)
isResolving: boolean;
resolved: boolean;
resolution?: SuspensionStatus;
outcome: ApprovalOutcome; // icon + label for the resolved receipt
error: string | null;
}

When the schema is richer than a flat object of scalars/enums (nested objects, arrays of objects, unions), fields is empty — render a custom component named via the suspension's render.component hint instead.

QuestionRenderer, SelectionRenderer, SchemaFormRenderer​

The default cards for user_input suspensions, all thin views over useSuspensionForm:

  • QuestionRenderer — a free-text answer (no flat schema).
  • SelectionRenderer — single choice (a z.enum) or multi (z.array(z.enum)).
  • SchemaFormRenderer — a flat object of scalars and enums, one control per property.

ItemRenderer auto-picks one for a suspension item with reason: "user_input" by render.component hint → reason → resumeSchema shape. A registered renderers.suspension overrides this, and renderers.suspension: false suppresses inline cards. user_approval suspensions still get ApprovalRenderer.

import { QuestionRenderer, SelectionRenderer, SchemaFormRenderer } from "@flow-state-dev/react";

useApproval​

Headless controller for a suspension approval — the logic with no markup. Owns the resume transport, in-flight/error state, the duplicate-resume guard, and the resolved outcome.

import { useApproval } from "@flow-state-dev/react";

function MyApproval({ item }) {
const a = useApproval(item);
if (a.resolved) return <span>{a.outcome.icon} {a.outcome.label}</span>;
return (
<>
<button disabled={!a.canApprove || a.isResolving} onClick={a.approve}>Approve</button>
<button disabled={!a.canReject || a.isResolving} onClick={a.reject}>Reject</button>
</>
);
}

Returns { approve, reject, pendingAction, isResolving, error, resolved, resolvedStatus, outcome, canApprove, canReject }. Resolution goes through onApprove/onReject if supplied, else the nearest <SuspensionResolverProvider> (streaming), else a non-streaming resume through the provider's client (needs flowKind on <FlowProvider>).

ApprovalRenderer​

The minimal built-in default that ItemRenderer uses for type === "suspension" items — plain, unstyled buttons so a suspension renders something actionable with zero setup, collapsing to a one-line text receipt once resolved. For a polished, themeable card, register the Approval component from @flow-state-dev/ui via the suspension renderer slot (it's in chatAssistantRenderers). Both are thin views over useApproval.

import { ApprovalRenderer } from "@flow-state-dev/react";

// Used automatically by ItemRenderer; or render directly with explicit handlers:
<ApprovalRenderer
item={suspensionItem}
onApprove={(data) => approve(item.suspensionId, data)}
onReject={(data) => reject(item.suspensionId, data)}
/>

When used inline (inside <ItemsRenderer> without explicit handlers), it reads FlowContext for flowKind/baseUrl/userId and resumes directly. ItemsRenderer threads the resolution outcome to this default, so a reloaded conversation shows whether it was approved or rejected. Suppress it with renderers={{ suspension: false }}.

See Suspensions and approvals for usage patterns.