React
Use @rytejs/react to drive React UI from workflow state. The package provides a reactive store, a useWorkflow hook, state matching, selector-based re-renders, and a context API for prop-drilling-free access.
Installation
pnpm add @rytejs/react @rytejs/core zod react
@rytejs/reacthas peer dependencies on@rytejs/coreandreact >= 18.
Creating a Store
createWorkflowStore wraps a WorkflowRouter and an initial state into a reactive store that tracks the current workflow, dispatching status, and errors.
const store = createWorkflowStore(router, {
state: "Todo",
data: { title: "Write docs", priority: 1 },
});
// Read the current workflow
const workflow = store.getWorkflow();
console.log(workflow.state); // "Todo"
// Dispatch a command
const result = await store.dispatch("Start", { assignee: "alice" });
if (result.ok) {
console.log(store.getWorkflow().state); // "InProgress"
}
// Subscribe to changes
const unsubscribe = store.subscribe(() => {
const snap = store.getSnapshot();
console.log(snap.workflow.state, snap.isDispatching);
});The store exposes:
| Method | Description |
|---|---|
getWorkflow() | Returns the current Workflow<TConfig> |
getSnapshot() | Returns { workflow, isDispatching, error } |
subscribe(listener) | Registers a change listener; returns an unsubscribe function |
dispatch(command, payload) | Dispatches a command through the router |
setWorkflow(workflow) | Replaces the workflow directly (for server-pushed updates) |
useWorkflow Hook
Inside a React component, useWorkflow(store) subscribes to the store and returns a reactive object. The component re-renders whenever the workflow, dispatching status, or error changes.
// In a React component, useWorkflow provides reactive access to the store:
//
// function TaskView({ store }: { store: WorkflowStore<TaskConfig> }) {
// const wf = useWorkflow(store);
//
// return (
// <div>
// <p>State: {wf.state}</p>
// <p>Dispatching: {wf.isDispatching ? "yes" : "no"}</p>
// {wf.error && <p>Error: {wf.error.category}</p>}
// <button onClick={() => wf.dispatch("Start", { assignee: "alice" })}>
// Start
// </button>
// </div>
// );
// }
// The hook returns UseWorkflowReturn<TConfig> with:
declare const wfHook: UseWorkflowReturn<TaskConfig>;
wfHook.workflow; // full Workflow<TConfig>
wfHook.state; // "Todo" | "InProgress" | "Done"
wfHook.data; // union of all state data types
wfHook.isDispatching; // true while a dispatch is in flight
wfHook.error; // PipelineError | null (last dispatch error)
wfHook.dispatch("Start", { assignee: "bob" }); // returns Promise<DispatchResult>State Matching
match() provides type-safe branching over workflow states -- similar to pattern matching. The callback for each state receives the correctly typed data and workflow.
declare const wf: UseWorkflowReturn<TaskConfig>;
// Exhaustive match — every state must be handled
const label: string = wf.match({
Todo: (data) => `Todo: ${data.title} (priority ${data.priority})`,
InProgress: (data) => `Working: ${data.title} (${data.assignee})`,
Done: (data) => `Done: ${data.title} at ${data.completedAt.toISOString()}`,
});
// Partial match — only handle some states, provide a fallback
const badge: string = wf.match(
{
InProgress: (data) => `Assigned to ${data.assignee}`,
},
(workflow) => `State: ${workflow.state}`,
);Exhaustive match requires a handler for every state. The compiler will error if you forget one.
Partial match handles only the states you care about. The fallback receives the full Workflow<TConfig> and runs for any unhandled state.
Selector Mode
When you only need a slice of the workflow, pass a selector function as the second argument. The component only re-renders when the selected value changes (compared with Object.is by default).
// Selector mode — only re-renders when the selected value changes:
//
// function TaskTitle({ store }: { store: WorkflowStore<TaskConfig> }) {
// const title = useWorkflow(store, (wf) => wf.data.title);
// return <h1>{title}</h1>;
// }
//
// Custom equality function for object selections:
//
// function TaskMeta({ store }: { store: WorkflowStore<TaskConfig> }) {
// const meta = useWorkflow(
// store,
// (wf) => ({ state: wf.state, id: wf.id }),
// (a, b) => a.state === b.state && a.id === b.id,
// );
// return <p>{meta.state} — {meta.id}</p>;
// }
// Type-safe: the selector receives Workflow<TConfig>
const title: string = useWorkflow(store, (w) => w.data.title);For object selections, provide a custom equality function as the third argument to avoid unnecessary re-renders.
Context API
createWorkflowContext creates a scoped React context so any descendant component can access the store without prop drilling.
const TaskContext = createWorkflowContext(taskWorkflow);
// Wrap your app with the Provider:
//
// function App() {
// const store = createWorkflowStore(router, {
// state: "Todo",
// data: { title: "Build feature" },
// });
// return (
// <TaskContext.Provider store={store}>
// <TaskPanel />
// </TaskContext.Provider>
// );
// }
// Any descendant can access the store without prop drilling:
//
// function TaskPanel() {
// const wf = TaskContext.useWorkflow();
// return <p>{wf.state}</p>;
// }
//
// function TaskTitle() {
// const title = TaskContext.useWorkflow((wf) => wf.data.title);
// return <h1>{title}</h1>;
// }The returned useWorkflow supports both full mode and selector mode, just like the standalone hook.
Persistence
Pass a persist option to createWorkflowStore to automatically save the workflow snapshot to localStorage (or any Storage-compatible backend) after each successful dispatch. On next load, the store restores from storage instead of using the initial config.
const persistedStore = createWorkflowStore(
router,
{
state: "Todo",
data: { title: "Persisted task", priority: 0 },
},
{
persist: {
key: "task-workflow",
storage: localStorage,
},
},
);
// After a successful dispatch, the workflow snapshot is automatically
// saved to localStorage under the key "task-workflow".
// On next page load, createWorkflowStore restores from storage
// instead of using the initial config.If the stored data is from an older modelVersion, pass a migrations pipeline:
import { defineMigrations } from "@rytejs/core";
const migrations = defineMigrations(taskWorkflow, {
2: (snap) => ({
...snap,
data: { ...snap.data, priority: 0 },
}),
});
const store = createWorkflowStore(
router,
{ state: "Todo", data: { title: "Migrated task", priority: 0 } },
{
persist: {
key: "task-workflow",
storage: localStorage,
migrations,
},
},
);See Migrations for details on defining migration pipelines.
Transport
Use createWorkflowClient to connect to a server-backed workflow. Commands dispatch through the server, and broadcasts push updates back to the client.
const exampleTransport: Transport = {
async load(id) {
// Load workflow from server — e.g., fetch("GET", `/api/workflows/${id}`)
throw new Error(`Not implemented: load(${id})`);
},
async dispatch(id, _command, _expectedVersion) {
// Dispatch command to server — e.g., fetch("POST", `/api/workflows/${id}`, ...)
throw new Error(`Not implemented: dispatch(${id})`);
},
subscribe(_id, _callback) {
// Subscribe to live updates — e.g., EventSource, WebSocket
return { unsubscribe() {} };
},
};
const client = createWorkflowClient(exampleTransport);
// connect() loads the workflow from the server and subscribes to updates
const remoteStore = client.connect(taskWorkflow, "task-1");
// Dispatch goes through the server instead of locally
await remoteStore.dispatch("Start", { assignee: "alice" });
// Incoming broadcasts update the store automaticallyTransport mode requires an id — the server needs to know which workflow to operate on.
Real-time Updates
The client automatically subscribes to server broadcasts. Incoming updates replace the local workflow state and trigger re-renders.
Cleanup
Call cleanup() to unsubscribe from the transport when the store is no longer needed:
// Unsubscribe from transport when done (e.g., React component unmount)
remoteStore.cleanup();Next Steps
- Error Handling -- handle dispatch failures in the UI
- Serialization -- understand snapshots and restore
- Migrations -- evolve stored workflow data over time
- Testing -- test workflows with
@rytejs/testing