Skip to content

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

bash
pnpm add @rytejs/react @rytejs/core zod react

@rytejs/react has peer dependencies on @rytejs/core and react >= 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.

ts
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:

MethodDescription
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.

ts
// 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.

ts
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).

ts
// 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.

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

ts
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:

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

ts
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 automatically

Transport 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:

ts
// Unsubscribe from transport when done (e.g., React component unmount)
remoteStore.cleanup();

Next Steps