https://github.com/benedictp/workflow-ts
A TypeScript implementation of Square's Workflow architecture for state-machine-driven applications
https://github.com/benedictp/workflow-ts
react state-machine typescript unidirectional workflow
Last synced: about 2 months ago
JSON representation
A TypeScript implementation of Square's Workflow architecture for state-machine-driven applications
- Host: GitHub
- URL: https://github.com/benedictp/workflow-ts
- Owner: BenedictP
- License: mit
- Created: 2026-02-21T15:50:36.000Z (3 months ago)
- Default Branch: main
- Last Pushed: 2026-04-07T07:08:32.000Z (about 2 months ago)
- Last Synced: 2026-04-07T09:11:08.341Z (about 2 months ago)
- Topics: react, state-machine, typescript, unidirectional, workflow
- Language: TypeScript
- Size: 902 KB
- Stars: 2
- Watchers: 0
- Forks: 0
- Open Issues: 2
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
- Codeowners: .github/CODEOWNERS
- Agents: AGENTS.md
Awesome Lists containing this project
README
# workflow-ts
[](https://github.com/BenedictP/workflow-ts/actions?query=workflow%3ACI)
[](https://bundlejs.com/?q=%40workflow-ts%2Fcore)
[](https://bundlejs.com/?q=%40workflow-ts%2Freact)
[](https://www.npmjs.com/package/@workflow-ts/core)
[](https://www.npmjs.com/package/@workflow-ts/react)
TypeScript implementation of Square's [Workflow architecture](https://developer.squareup.com/blog/workflow-compose/) for explicit, testable, state-machine-driven application logic.
## Why workflow-ts
- 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
### Further reading
- [Your UI Has States — Start Treating Them That Way](https://medium.com/@benedict.pregler/your-ui-has-states-start-treating-them-that-way-ade30be1e72e)
- [Zustand Gives You Freedom, workflow-ts Gives You Guardrails](https://medium.com/@benedict.pregler/zustand-gives-you-freedom-workflow-ts-gives-you-guardrails-6d4634b724aa)
- [Stop Writing State Machine Config, Start Writing Functions](https://medium.com/@benedict.pregler/stop-writing-state-machine-config-start-writing-functions-50254e3daa39)
## When to use it
Use workflow-ts when you want explicit, deterministic state transitions and a clear separation between business logic orchestration and UI rendering.
## Install
```bash
pnpm add @workflow-ts/core
# React bindings:
pnpm add @workflow-ts/react
```
## Quick Start: One Cohesive Example
This example models a small "load profile" flow and is reused in the concept snippets below.
Canonical runnable source: [`examples/readme-profile`](./examples/readme-profile).
### 0. High-Level Architecture

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.
### 1. Define the workflow (`@workflow-ts/core`)
```typescript
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;
}
// Simulate an async profile fetch that also honors cancellation.
const createLoadProfileWorker = (): Worker => {
return createWorker('load-profile', async (signal) => {
await new Promise((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 => ({
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](./docs/guides/overview.md), [Workers](./docs/guides/workers.md)
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.
### 2. Subscribe in React (`@workflow-ts/react`)
```tsx
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 (
Profile
Loading...
Close
);
case 'loaded':
// Loaded renderings expose both data and follow-up actions.
return (
Welcome {rendering.name}
Reload
Close
);
case 'error':
// Error renderings carry a message plus a recovery action.
return (
Profile
{rendering.message}
Retry
Close
);
}
}
```
Deep dive: [React Integration](./docs/guides/react.md), [Next.js SSR & Hydration](./docs/guides/nextjs-ssr-hydration.md)
### 3. Test without UI
```typescript
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).name).toBe('Ada');
// Dispose the runtime to clean up workers and subscriptions.
runtime.dispose();
});
```
Deep dive: [Testing](./docs/guides/testing.md)
## Core Concepts
These are concise mechanics. For complete walkthroughs, start at [Documentation Index](./docs/index.md).
### State
State is internal and immutable. Model each meaningful step explicitly:
```typescript
type State =
| { type: 'loading' }
| { type: 'loaded'; name: string }
| { type: 'error'; message: string };
```
More: [Overview](./docs/guides/overview.md)
### Actions
Actions are pure reducers that return next state and optional output:
```typescript
ctx.actionSink.send((state) =>
state.type === 'error'
? { state: { type: 'loading' } }
: { state },
);
```
More: [Overview](./docs/guides/overview.md), [Composition](./docs/guides/composition.md)
### Rendering
Rendering is the framework-agnostic view model (data + callbacks):
```typescript
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](./docs/guides/overview.md), [React Integration](./docs/guides/react.md)
### Workers
Workers run async tasks and are started/stopped by render calls:
```typescript
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](./docs/guides/workers.md)
### Composition
Parents render children and map child outputs back into parent actions:
```typescript
const child = ctx.renderChild(childWorkflow, childProps, 'child-key', (output) => (state) => ({
state,
output,
}));
```
More: [Composition & Child Workflows](./docs/guides/composition.md)
### Snapshots
You can persist and restore workflow state with `snapshot`/`restore`:
```typescript
snapshot: (state) => JSON.stringify(state),
restore: (snapshot) => JSON.parse(snapshot),
```
More: [Snapshots](./docs/guides/snapshots.md)
## Documentation Map
Start here: [Documentation Index](./docs/index.md)
### Getting Started
- [Overview](./docs/guides/overview.md)
- [React Integration](./docs/guides/react.md)
- [Next.js SSR & Hydration](./docs/guides/nextjs-ssr-hydration.md)
### Workflow Mechanics
- [Composition & Child Workflows](./docs/guides/composition.md)
- [Workers](./docs/guides/workers.md)
### Reliability
- [Testing](./docs/guides/testing.md)
- [Snapshots](./docs/guides/snapshots.md)
## Examples
See [examples/](./examples):
- [README Profile](./examples/readme-profile/README.md) - runnable source-of-truth for the Quick Start snippets
- [Counter](./examples/counter/README.md) - minimal state/action workflow
## Package References
- [@workflow-ts/core API](./packages/core/README.md)
- [@workflow-ts/react API](./packages/react/README.md)
## AI agent Skill
Install the workflow-ts skill with the `skills` CLI:
```bash
npx skills add BenedictP/workflow-ts
```
Then use the skill in prompts as `$workflow-ts-architecture`.
Docs: [Skills CLI](https://skills.sh/docs/cli), [FAQ](https://skills.sh/docs/faq), [Overview](https://skills.sh/docs)
## Development
```bash
pnpm install
pnpm test
pnpm build
pnpm typecheck
pnpm ci
```
## Acknowledgments
Inspired by Square's [Workflow library](https://github.com/square/workflow-kotlin) and Point-Free's [TCA](https://github.com/pointfreeco/swift-composable-architecture).
## License
MIT