TypeScript · State Machines · UI-Agnostic Core

Your UI has states.
Start treating them that way.

workflow-ts is a TypeScript implementation of Square's Workflow architecture. Explicit states. Unidirectional actions. Render-scoped workers. A framework-agnostic core with React bindings in a separate package.

  • 4.68 kB gzip @workflow-ts/core
  • 9 kB gzip @workflow-ts/react
  • 0 runtime deps core package
  • 100% typed strict mode
§ 01

Why a workflow?

Most UI code accretes flags: isLoading, hasError, isSubmitting, hasSubmittingError. The machine becomes implicit and untestable. workflow-ts makes the states explicit and the transitions the only way forward.

§ 02

The contract, in three files.

One workflow module. One React screen. One test. Same mental model everywhere.

workflow.ts from @workflow-ts/core
import { createWorker, type Worker, type Workflow } from '@workflow-ts/core';

// Props: inputs from the hosting screen or parent workflow.
export interface Props { userId: string }

// State: every meaningful step the screen can be in, as a tagged union.
// There are no illegal combinations; the machine is always in exactly one case.
export type State =
  | { type: 'loading' }
  | { type: 'loaded'; name: string }
  | { type: 'error'; message: string };

// Output: events this workflow emits upward to its parent.
export interface Output { type: 'closed' }

// Rendering: the framework-agnostic view model. One shape per state.
// The UI only ever sees one of these; callbacks are how it sends events back.
export type Rendering =
  | { type: 'loading'; close: () => void }
  | { type: 'loaded'; name: string; reload: () => void; close: () => void }
  | { type: 'error'; message: string; retry: () => void; close: () => void };

export const profileWorkflow: Workflow<Props, State, Output, Rendering> = {
  // Start in 'loading'. A snapshot can override this during rehydration.
  initialState: () => ({ type: 'loading' }),

  // render() runs on every state change. Pure function of (props, state).
  // It returns a Rendering and may start render-scoped workers for async work.
  render: (_props, state, ctx) => {
    switch (state.type) {
      case 'loading':
        // Start the profile-load worker while this state is active.
        // When the worker resolves, its result becomes the next state.
        // If we leave 'loading' before it finishes, the worker is cancelled.
        ctx.runWorker(loadProfileWorker, 'profile-load', (r) => () => ({
          state: r.ok
            ? { type: 'loaded', name: r.name }
            : { type: 'error', message: r.message },
        }));
        return { type: 'loading', close: () => emitClosed(ctx) };

      case 'loaded':
        return {
          type: 'loaded',
          name: state.name,
          // Reload sends an action that transitions back to 'loading',
          // which (above) restarts the worker. No imperative fetch here.
          reload: () => ctx.actionSink.send(() => ({ state: { type: 'loading' } })),
          close:  () => emitClosed(ctx),
        };

      case 'error':
        return {
          type: 'error',
          message: state.message,
          // Retry is the same transition as reload: go back to 'loading'.
          retry: () => ctx.actionSink.send(() => ({ state: { type: 'loading' } })),
          close: () => emitClosed(ctx),
        };
    }
  },
};
§ 03

The runtime, drawn to scale.

Props enter. State is explicit. Render produces a framework-agnostic Rendering. Actions and Worker results close the loop. Output bubbles up to the parent.

workflow-ts runtime architecture: UI to Action to State to Workflow Definition to Rendering, with Worker as a dashed side-lane and Output bubbling out to the parent
FIG. 02 — Runtime data flow. UI → Action → State → Workflow → Rendering → UI. Workers run beneath as a cancellable side-effect lane.
§ 04

Six concepts. That's the whole library.

  1. State A tagged union of every meaningful step the screen can be in.
  2. Action A pure reducer from state to next state, with optional output.
  3. Rendering The framework-agnostic view model: data plus callbacks.
  4. Worker Render-scoped async work with automatic cancellation.
  5. Composition Parent workflows render children and map their outputs back.
  6. Snapshot Serialize current state. Rehydrate through initialState later.
§ 05

Install & get reading.

Core

pnpm add @workflow-ts/core

Zero runtime dependencies. 4.68 kB gzip.

React bindings

pnpm add @workflow-ts/react

A single hook: useWorkflow. SSR-safe.

AI agent skill

npx skills add BenedictP/workflow-ts

Installable $workflow-ts-architecture skill.