Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Managing state
  2. State machines

State machines#

craftStateMachine models a finite workflow as a named, typed, reactive primitive. It is useful when a feature has a small set of meaningful modes — for example, an editor that is either reading or editing, a form that moves through validation and submission, or a resource that moves through loading, success and failure.

The important part is not only that the machine has states. It is that the states and the transitions are 100% declarative: the machine describes which events can enter each state, and Craft derives the current state from those declarations at runtime.

A different perspective on transitions#

Many state-machine APIs describe a transition from the current state:

while in reading, when edit happens, go to editing

That perspective makes the target explicit in the transition itself. You look at the reading state's handlers to discover where an edit event goes.

Craft reverses the perspective. Each entry in the transitions record describes how to enter that step. The record key is the target step, and transit() inside that step's block means “attempt to enter this step”. It does not take a state name because the surrounding key already supplies it.

If you are used to XState or a similar state-machine API, the difference is the direction in which you read the same workflow graph. You may usually start from reading and ask “where does edit go?”. In Craft, you start from editing and ask “which event makes the machine enter editing?”. The graph is still explicit; its declarations are owned by their destination step.

typescript
function* (context, transit) {
  return {
    reading: transitionStep(function* () {
      yield* initStateMachine(() => transit());
      yield* on$(context.commit$, () => transit());
      yield* on$(context.cancel$, () => transit());
    }),

    editing: transitionStep(function* () {
      yield* on$(context.edit$, () => transit());
    }),
  };
}

Reading this declaration tells you immediately:

  • reading is entered during initialisation, after commit, or after cancel;

  • editing is entered after edit.

There is no currentStep = ..., no imperative transition table, and no string such as transit('editing'). The destination is the step whose block declared the event. If an event attempts to enter the step that is already active, the attempt is a no-op.

This makes the transition logic especially easy to inspect: to answer “when can the machine enter reading?”, read the reading block and look at the events it listens to. The machine's transition behavior is visible in the declarations themselves.

The same principle applies to the steps themselves. Each step registered in a craftStateMachine can be 100% declarative: its context can be assembled from Craft primitives, its event reactions can be expressed with on$, and its view can be selected from the typed step context. A step does not need an imperative “enter” function that manually changes the machine or coordinates the rest of the feature.

The text editor example#

The demo application contains a complete declarative text editor example. It has two steps:

reading  ← initialisation, commit, cancel
editing  ← edit

The machine's context owns the events and the text state. The transitions only declare which events enter which step:

typescript
const machine =
  yield *
  craftStateMachine(
    'textEditor',

    function* () {
      const edit$ = yield* source$<void>('text.edit');
      const commit$ = yield* source$<void>('text.commit');
      const cancel$ = yield* source$<void>('text.cancel');

      const text = yield* state(
        'text',
        { committedValue: '', value: '' },
        insertStatePipe(insertDeepYieldable(), ({ patch }) => ({
          change: (value: string) => patch(() => ({ value })),
          commit: on$(commit$, () =>
            patch((current) => ({ committedValue: current.value })),
          ),
          cancel: on$(cancel$, () =>
            patch((current) => ({ value: current.committedValue })),
          ),
        })),
      );

      return { edit$, commit$, cancel$, text };
    },

    function* (context, transit) {
      return {
        reading: transitionStep(function* () {
          yield* initStateMachine(() => transit());
          yield* on$(context.commit$, () => transit());
          yield* on$(context.cancel$, () => transit());
        }),
        editing: transitionStep(function* () {
          yield* on$(context.edit$, () => transit());
        }),
      };
    },

    function* ({ text, cancel$, commit$, edit$ }) {
      return {
        reading: { text, edit$ },
        editing: { text, commit$, cancel$ },
      };
    },
  );

The first factory creates the shared context. The second factory declares the machine's steps and their incoming events. The third factory gives each step a typed context for its view: the reading view can edit, while the editing view can commit or cancel.

insertDeepYieldable() makes the object-valued text state deeply readable. The template can therefore bind to reading.text.value and reading.text.committedValue without creating a separate craftComputed for each property.

Rendering the current step#

The machine exposes currentStep as a union of step names and currentStepWithContext as a discriminated union. Use the latter when each step needs different data or actions:

typescript
matchNode.exhaustive(machine.currentStepWithContext, 'step', {
  reading: (reading) =>
    div([
      p(['Committed value: ', reading.text.committedValue]),
      p(['Current value: ', reading.text.value]),
      button({ click: () => reading.edit$.emit() }, 'Edit'),
    ]),

  editing: (editing) =>
    div([
      input({
        value: editing.text.value,
        input: function* (event) {
          yield* editing.text.change(event.target.value);
        },
      }),
      button({ click: () => editing.commit$.emit() }, 'Commit'),
      button({ click: () => editing.cancel$.emit() }, 'Cancel'),
    ]),
});

matchNode.exhaustive checks that every step is handled, and narrows the handler argument to that step's context. Adding a new step therefore produces compile-time feedback both in the machine's transition record and in the rendering code.

If the view only needs the name, use the shorter scalar form:

typescript
matchNode.exhaustive(machine.currentStep, {
  reading: () => p('Reading'),
  editing: () => p('Editing'),
});

Guards#

The event declaration says when a transition is attempted. A transitionGuard says whether that attempt is accepted. Guards can be local to one step, global to the machine, or attached to one particular attempt:

typescript
editing: transitionStep(function* () {
  yield* on$(context.edit$, () =>
    transit().pipe(
      transitionGuard(({ context }) => context.form.isValid()),
    ),
  );
}),

A guard can also be a generator and yield Craft services. Those dependencies become part of the machine's dependency graph. This keeps the condition declarative as well: the transition is still described by its event and its accepted predicate, rather than by an imperative event handler that manually coordinates state.

Effect services in a guard#

In an Effect-enabled frontend, use transitionGuardEffect when the decision is declared synchronous and needs an Effect service. It is the Effect-aware form of transitionGuard:

typescript
import { Context, Effect, Layer } from 'effect';
import { SyncOp, transitionGuardEffect } from '@craft-ts/effect';

type CheckoutPolicyShape = {
  readonly canSubmit: (total: number) => Effect.Effect<boolean, never, SyncOp>;
};

class CheckoutPolicy extends Context.Service<
  CheckoutPolicy,
  CheckoutPolicyShape
>()('app/CheckoutPolicy') {}

const CheckoutPolicyLive = Layer.succeed(CheckoutPolicy, {
  canSubmit: (total) =>
    Effect.gen(function* () {
      yield* SyncOp;
      return total > 0;
    }),
});

editing: transitionStep(function* () {
  yield* on$(context.submit$, () =>
    transit().pipe(
      transitionGuardEffect(() =>
        Effect.gen(function* () {
          yield* SyncOp;
          const policy = yield* CheckoutPolicy;
          return yield* policy.canSubmit(context.total());
        }),
      ),
    ),
  );
});

transitionGuardEffect runs through syncEffect, so SyncOp is required at compile time and an Effect that suspends is rejected. The Effect service used by the guard is also carried into the machine's dependency graph. Provide its implementation with provideLayer(...) at the application, component or route scope:

typescript
const routes = craftRoutes('checkout', [
  {
    path: '',
    ...loadCraftComponent(
      () => import('./checkout').then(({ default: component }) => component),
      [provideLayer(CheckoutPolicyLive)] as const,
    ),
  },
]);

For an asynchronous policy check, do not put the Effect in a guard. Use asyncProcessEffect, queryEffect or mutationEffect, then model the pending, success and failure outcomes as explicit machine steps. A transition guard must answer synchronously; an asynchronous decision is a workflow state.

Composition and extensions#

The final machine insertion has the same role as an insertion on state, query, or mutation. Use it for derived values, view helpers, selectors, or reusable behavior. For several machine insertions, use insertStateMachinePipe:

typescript
const machine =
  yield *
  craftStateMachine(
    'editor',
    contextFactory,
    transitions,
    stepContextFactory,
    insertStateMachinePipe(
      withStateMachineHistory({
        persist: { storeName: 'demo', key: 'editor' },
      }),
      withBackNavigation(),
      ({ currentStep }) => ({
        isReading: craftUse(craftComputed('isReading', function* () {
          return (yield* currentStep()) === 'reading';
        })),
      }),
    ),
  );

History, back/forward navigation, and derived flags are therefore extensions of the machine rather than hidden responsibilities of its core. The machine remains focused on declaring steps and the events that enter them.

When to use a state machine#

Use craftStateMachine when:

  • the feature has a finite set of named workflow steps;

  • different steps expose different actions or view data;

  • events, recomputations, or initialisation determine when a step is entered;

  • exhaustive handling of steps is valuable;

  • guards or reusable workflow extensions belong on the state-machine boundary.

For a single independent value, use state. For a server read or write, use query or mutation. A state machine can compose those primitives in its context when the workflow needs them.

API summary#

APIRole
craftStateMachineCreates the named state-machine primitive
transitionStepDeclares how one step is entered
transit()Creates an attempt to enter the surrounding step
initStateMachineDeclares the attempt that establishes the initial step
transitionGuardAccepts or rejects a transition attempt
transitionGuardEffectEffect-aware guard for declared-synchronous Effects
currentStepReactive union of step names
currentStepWithContextReactive discriminated union of step contexts
insertStateMachinePipeComposes machine insertions

See also#

  • Local state

  • Typed insertion pipes

  • Fine-grained reactivity

  • The text editor example on GitHub

←Local statequery→

On this page

  • A different perspective on transitions
  • The text editor example
  • Rendering the current step
  • Guards
  • Effect services in a guard
  • Composition and extensions
  • When to use a state machine
  • API summary
  • See also

@craft-ts · MIT licensed

GitHubnpm

Search