@workflow-ts/react
v0.1.2
Published
React bindings for workflow-ts
Maintainers
Readme
@workflow-ts/react
React hooks for workflow-ts.
Installation
pnpm add @workflow-ts/react @workflow-ts/coreRecommended Architecture
Use workflow-ts as a rendering subscription + mapping system:
- Subscribe to workflow rendering with
useWorkflow. - Map that rendering tree to React components.
import { useWorkflow } from '@workflow-ts/react';
function AppScreen({ userId }: { userId: string }) {
const rendering = useWorkflow(appWorkflow, { userId });
return <AppRenderer rendering={rendering} />;
}This keeps workflow logic inside workflows and keeps React focused on rendering.
Performance
- Preferred setup: React Compiler enabled in the consuming app.
- With React Compiler, manual
React.memois usually unnecessary. - Keep props/rendering references stable to minimize work.
- Keep workflow props small and immutable (prefer flat scalar values like ids/flags/strings).
- Avoid passing large nested object graphs as hook props; pass only minimal derived inputs.
Props Contract
useWorkflow and useWorkflowWithState expose a TypeScript AllowedProp contract for hook props.
At runtime, unsupported values are validated and rejected only in development environments (React Native __DEV__, NODE_ENV !== 'production', or bundler dev flags).
Allowed values:
- primitives (
string,number,boolean,bigint,symbol,null,undefined) - functions
- arrays
- plain objects (
Object.prototypeornullprototype) Date,Map,SetArrayBuffer,DataView, typed arrays
Rejected values:
- class instances
- branded built-ins outside the allowlist (
URL,Error,RegExp, etc.) Promise,WeakMap,WeakSet
Hooks
useWorkflow(workflow, props, onOutput?, options?)
Subscribe to a workflow's rendering. Re-renders component when workflow state changes.
import { useWorkflow } from '@workflow-ts/react';
import { type Workflow } from '@workflow-ts/core';
const counterWorkflow: Workflow<void, State, never, Rendering> = {
// ... workflow definition
};
function Counter() {
const { count, onIncrement, onDecrement } = useWorkflow(
counterWorkflow,
undefined, // props
);
return (
<div>
<span>{count}</span>
<button onClick={onIncrement}>+</button>
<button onClick={onDecrement}>-</button>
</div>
);
}Parameters:
workflow- The workflow definitionprops- Props to pass to the workflow (must satisfy the plain-only contract above)onOutput?- Optional callback for workflow outputsoptions?- Optional hook options
Options:
resetOnWorkflowChange?: boolean- Recreate runtime when workflow identity changes (opt-in). Defaults tofalse. To hard-reset in React, consider using a componentkey.outputHandlers?: { [K in O extends { type: string } ? O['type'] : never]?: (output: Extract<O, { type: K }>) => void }- Typed per-output handlers for discriminated union outputs.- Hooks are compatible with React StrictMode development replays.
- Disposed runtimes in
@workflow-ts/corestill throw when used (strict disposal contract). lifecycle?: 'always-on' | 'pause-when-backgrounded'- Runtime lifecycle mode. Defaults to'always-on'.isActive?: boolean- Active state used with'pause-when-backgrounded'. Defaults totrue.- In pause mode, explicit
isActive: true -> falsetransitions dispose runtime immediately.
Returns: The current rendering (type R from workflow)
useWorkflowWithState(workflow, options)
Like useWorkflow, but also exposes runtime controls.
import { useWorkflowWithState } from '@workflow-ts/react';
function SearchComponent() {
const { rendering, state, updateProps, snapshot } = useWorkflowWithState(searchWorkflow, {
props: { query: '' },
onOutput: (output) => console.log('Output:', output),
});
return (
<div>
<input value={state.query} onChange={(e) => updateProps({ query: e.target.value })} />
<ul>
{rendering.results.map((r) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
</div>
);
}Options:
props: P- Initial props (must satisfy the plain-only contract above)onOutput?: (output: O) => void- Output callbackoutputHandlers?: { [K in O extends { type: string } ? O['type'] : never]?: (output: Extract<O, { type: K }>) => void }- Typed per-output handlers for discriminated union outputs.resetOnWorkflowChange?: boolean- Recreate runtime when workflow identity changes (opt-in). Defaults tofalse.- StrictMode development replay is supported without changing core runtime disposal semantics.
lifecycle?: 'always-on' | 'pause-when-backgrounded'- Runtime lifecycle mode. Defaults to'always-on'.isActive?: boolean- Active state used with'pause-when-backgrounded'. Defaults totrue.- Inactive controls are safe:
updatePropsno-ops andsnapshotreturns last-known value (orundefined).
Returns:
rendering: R- Current renderingstate: S- Current state (for debugging)props: P- Current propsupdateProps: (props: P) => void- Update propssnapshot: () => string | undefined- Get state snapshot
React Native lifecycle example
Use AppState to pause runtime activity while the app is backgrounded:
import { AppState } from 'react-native';
import { useEffect, useState } from 'react';
import { useWorkflow } from '@workflow-ts/react';
function Screen() {
const [isActive, setIsActive] = useState(AppState.currentState === 'active');
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState) => {
setIsActive(nextState === 'active');
});
return () => subscription.remove();
}, []);
const rendering = useWorkflow(workflow, props, undefined, {
lifecycle: 'pause-when-backgrounded',
isActive,
});
return <Content rendering={rendering} />;
}Example: Async Data Fetching
import { useEffect } from 'react';
import { useWorkflow } from '@workflow-ts/react';
import { type Workflow, createWorker } from '@workflow-ts/core';
type State =
| { type: 'idle' }
| { type: 'loading' }
| { type: 'success'; users: User[] }
| { type: 'error'; message: string };
interface Rendering {
isLoading: boolean;
users: User[];
error: string | null;
load: () => void;
}
const loadUsersWorker = createWorker('load-users', async (signal) => {
const res = await fetch('/api/users', { signal });
return res.json();
});
const usersWorkflow: Workflow<void, State, never, Rendering> = {
initialState: () => ({ type: 'idle' }),
render: (_props, state, ctx) => {
if (state.type === 'loading') {
ctx.runWorker(loadUsersWorker, 'load', (users) => () => ({
state: { type: 'success', users },
}));
}
return {
isLoading: state.type === 'loading',
users: state.type === 'success' ? state.users : [],
error: state.type === 'error' ? state.message : null,
load: () => ctx.actionSink.send(() => ({ state: { type: 'loading' } })),
};
},
};
function UserList() {
const { isLoading, users, error, load } = useWorkflow(usersWorkflow, undefined);
useEffect(() => {
load();
}, []);
if (isLoading) return <Spinner />;
if (error) return <Error message={error} />;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Example: Props-Driven Workflow
import { useState } from 'react';
import { useWorkflow } from '@workflow-ts/react';
import { type Workflow } from '@workflow-ts/core';
// Workflow that derives state from props
const searchWorkflow: Workflow<{ query: string }, State, never, Rendering> = {
initialState: (props) => ({ query: props.query, results: [] }),
render: (props, state, ctx) => {
// Update state when props change
if (props.query !== state.query) {
ctx.actionSink.send((s) => ({ state: { ...s, query: props.query } }));
}
return {
query: state.query,
results: state.results,
};
},
};
function Search() {
const [input, setInput] = useState('');
const { results } = useWorkflow(searchWorkflow, { query: input });
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<ul>
{results.map((r) => (
<li key={r.id}>{r.name}</li>
))}
</ul>
</div>
);
}Testing Components
import { render, screen, fireEvent } from '@testing-library/react';
import { useWorkflow } from '@workflow-ts/react';
test('counter increments', () => {
render(<Counter />);
expect(screen.getByText('0')).toBeInTheDocument();
fireEvent.click(screen.getByText('+'));
expect(screen.getByText('1')).toBeInTheDocument();
});TypeScript Tips
Extract Types
// Define types separately for reuse
interface CounterState {
count: number;
}
interface CounterRendering {
count: number;
onIncrement: () => void;
onDecrement: () => void;
}
type CounterOutput = { type: 'reachedZero' } | { type: 'reachedTen' };
const counterWorkflow: Workflow<void, CounterState, CounterOutput, CounterRendering> = {
// ...
};
// Use in component
function Counter() {
const rendering: CounterRendering = useWorkflow(counterWorkflow, undefined);
// ...
}Generic Components
interface WorkflowProps<P, R> {
workflow: Workflow<P, any, any, R>;
props: P;
}
function WorkflowComponent<P, R>({ workflow, props }: WorkflowProps<P, R>) {
const rendering = useWorkflow(workflow, props);
// ...
}License
MIT
