TypeScript implementation of Square's Workflow architecture for explicit, testable, state-machine-driven application logic.
- Explicit state machines instead of scattered UI flags
- Unidirectional action flow for predictable transitions
- Composable parent-child workflows
- Async work with render-scoped lifecycle via workers
- UI-agnostic core runtime with React hooks in a separate package
- Your UI Has States — Start Treating Them That Way
- Zustand Gives You Freedom, workflow-ts Gives You Guardrails
- Stop Writing State Machine Config, Start Writing Functions
Use workflow-ts when you want explicit, deterministic state transitions and a clear separation between business logic orchestration and UI rendering.
pnpm add @workflow-ts/core
# React bindings:
pnpm add @workflow-ts/reactThis example models a small "load profile" flow and is reused in the concept snippets below.
Canonical runnable source: examples/readme-profile.
At a high level, Props enter a workflow runtime, and the runtime stores explicit State. Every state transition triggers a render call, and render must return a framework-agnostic Rendering (data + callbacks) for the current state. UI callbacks send Actions back into the runtime to transition state, Workers feed async results into the same action loop, and optional Output values bubble events to the parent workflow or hosting screen.
import { createWorker, type Worker, type Workflow } from '@workflow-ts/core';
// Props enter the workflow from the hosting screen.
export interface Props {
userId: string;
}
// State is the internal state machine.
export type State =
| { type: 'loading' }
| { type: 'loaded'; name: string }
| { type: 'error'; message: string };
// Output is emitted upward when the flow is done.
export interface Output {
type: 'closed';
}
// Rendering is the UI contract returned from render().
export type Rendering =
| { type: 'loading'; close: () => void }
| { type: 'loaded'; name: string; reload: () => void; close: () => void }
| { type: 'error'; message: string; retry: () => void; close: () => void };
// Worker results feed back into state transitions.
type LoadProfileResult = { ok: true; name: string } | { ok: false; message: string };
// Tests can inject custom workers through this provider.
export interface WorkersProvider {
loadProfileWorker: Worker<LoadProfileResult>;
}
// Simulate an async profile fetch that also honors cancellation.
const createLoadProfileWorker = (): Worker<LoadProfileResult> => {
return createWorker<LoadProfileResult>('load-profile', async (signal) => {
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
resolve();
}, 5);
signal.addEventListener(
'abort',
() => {
// Abort clears the timer so the worker can finish immediately.
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
if (signal.aborted) {
return { ok: false, message: 'Cancelled' };
}
return { ok: true as const, name: 'Ada' };
});
};
const defaultWorkersProvider: WorkersProvider = {
loadProfileWorker: createLoadProfileWorker(),
};
// Allow worker injection so tests can control success and failure paths.
export const createProfileWorkflow = (
workersProvider: WorkersProvider = defaultWorkersProvider,
): Workflow<Props, State, Output, Rendering> => ({
initialState: () => ({ type: 'loading' }),
render: (_props, state, ctx) => {
switch (state.type) {
case 'loading':
// Start the load worker while this rendering is active.
ctx.runWorker(workersProvider.loadProfileWorker, 'profile-load', (result) => () => ({
state: result.ok
? { type: 'loaded', name: result.name }
: { type: 'error', message: result.message },
}));
return {
type: 'loading',
close: () => {
// Emit an output without changing the current state.
ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } }));
},
};
case 'loaded':
return {
type: 'loaded',
name: state.name,
reload: () => {
// UI events send actions back into the workflow.
ctx.actionSink.send(() => ({ state: { type: 'loading' } }));
},
close: () => {
ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } }));
},
};
case 'error':
return {
type: 'error',
message: state.message,
retry: () => {
// Retry by sending the state machine back to loading.
ctx.actionSink.send(() => ({ state: { type: 'loading' } }));
},
close: () => {
ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } }));
},
};
}
},
});
export const profileWorkflow = createProfileWorkflow();Deep dive: Overview, Workers
Worker lifecycle notes include keyed side-effect semantics and one-shot analytics/idempotency patterns.
Render convention: keep render primarily as switch (state.type). Use pre-switch code only for worker startup that must run in every state.
import { useWorkflow } from '@workflow-ts/react';
import type { JSX } from 'react';
import { profileWorkflow } from './workflow';
export function ProfileScreen({ userId }: { userId: string }): JSX.Element {
// Subscribe to the workflow and get the latest rendering for these props.
const rendering = useWorkflow(profileWorkflow, { userId });
// Each rendering case maps directly to the UI for that state.
switch (rendering.type) {
case 'loading':
// The worker is still running, so only Close is available.
return (
<section>
<h1>Profile</h1>
<p>Loading...</p>
<button onClick={rendering.close}>Close</button>
</section>
);
case 'loaded':
// Loaded renderings expose both data and follow-up actions.
return (
<section>
<h1>Welcome {rendering.name}</h1>
<button onClick={rendering.reload}>Reload</button>
<button onClick={rendering.close}>Close</button>
</section>
);
case 'error':
// Error renderings carry a message plus a recovery action.
return (
<section>
<h1>Profile</h1>
<p>{rendering.message}</p>
<button onClick={rendering.retry}>Retry</button>
<button onClick={rendering.close}>Close</button>
</section>
);
default:
// Exhaustiveness check - this should never happen
throw new Error(`Unknown rendering type: ${(rendering as { type: string }).type}`);
}
}Deep dive: React Integration, Next.js SSR & Hydration
import { createRuntime } from '@workflow-ts/core';
import { expect, it } from 'vitest';
import { profileWorkflow } from '../src/workflow';
it('transitions loading -> loaded', () => {
// Create a runtime so the workflow can be tested without mounting UI.
const runtime = createRuntime(profileWorkflow, { userId: 'u1' });
// The workflow should start in the loading state and rendering.
expect(runtime.getRendering().type).toBe('loading');
expect(runtime.getState().type).toBe('loading');
// Drive the next transition the same way a UI callback would.
runtime.send(() => ({ state: { type: 'loaded', name: 'Ada' } }));
const loaded = runtime.getRendering();
expect(loaded.type).toBe('loaded');
expect((loaded as Extract<typeof loaded, { type: 'loaded' }>).name).toBe('Ada');
// Dispose the runtime to clean up workers and subscriptions.
runtime.dispose();
});Deep dive: Testing
These are concise mechanics. For complete walkthroughs, start at Documentation Index.
State is internal and immutable. Model each meaningful step explicitly:
type State =
| { type: 'loading' }
| { type: 'loaded'; name: string }
| { type: 'error'; message: string };More: Overview
Actions are pure reducers that return next state and optional output:
ctx.actionSink.send((state) =>
state.type === 'error' ? { state: { type: 'loading' } } : { state },
);More: Overview, Composition
Rendering is the framework-agnostic view model (data + callbacks):
render: (_props, state, ctx) => {
switch (state.type) {
case 'loading':
return { type: 'loading' };
case 'loaded':
return { type: 'loaded', name: state.name };
case 'error':
return { type: 'error', message: state.message };
}
},More: Overview, React Integration
Workers run async tasks and are started/stopped by render calls:
switch (state.type) {
case 'loading':
ctx.runWorker(loadProfileWorker, 'profile-load', (result) => () => ({
state: result.ok
? { type: 'loaded', name: result.name }
: { type: 'error', message: result.message },
}));
return { type: 'loading' };
case 'loaded':
return { type: 'loaded', name: state.name };
case 'error':
return { type: 'error', message: state.message };
}In full workflows, keep rendering/state handling in a switch (state.type) and reserve pre-switch logic for unconditional worker startup only.
More: Workers
Parents render children and map child outputs back into parent actions:
const child = ctx.renderChild(childWorkflow, childProps, 'child-key', (output) => (state) => ({
state,
output,
}));More: Composition & Child Workflows
To persist state, call snapshot(state) and store the returned string.
To restore state, pass that string back through initialState(props, snapshot) when creating the runtime:
initialState: (_props, snapshot) => (snapshot ? JSON.parse(snapshot) : { count: 0 }),
snapshot: (state) => JSON.stringify(state),More: Snapshots
You can also wire automatic persistence on every state transition:
import { createPersistedRuntime, localStorageStorage } from '@workflow-ts/core';
const runtime = createPersistedRuntime(workflow, props, {
storage: localStorageStorage(),
key: 'profile:v1:u1',
version: 2,
rehydrate: 'lazy',
serialize: (state) => JSON.stringify(state),
deserialize: (raw, _props) => JSON.parse(raw),
});More: Persistence
Start here: Documentation Index
See examples/:
- README Profile - runnable source-of-truth for the Quick Start snippets
- Counter - minimal state/action workflow
Install the workflow-ts skill with the skills CLI:
npx skills add BenedictP/workflow-tsThen use the skill in prompts as $workflow-ts-architecture.
Docs: Skills CLI, FAQ, Overview
pnpm install
pnpm test
pnpm build
pnpm typecheck
pnpm run ciInspired by Square's Workflow library and Point-Free's TCA.
MIT
