PhaseState: Minimal State Machine

State machines are one of those patterns that sound academic until you actually need one.

A form wizard with validation between steps. A connection that can be connecting, connected, disconnecting, or error. A game character that can be idle, walking, jumping, or dead, and dead should not transition to jumping.

Every time I reach for a state machine library, I end up fighting it. Too much boilerplate, weird DSL, or a 50KB bundle for something that should be simple.

So I built PhaseState.


What it looks like

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { machine } from 'phasestate';

const loader = machine("idle", { count: 0 })
  .when("idle", {
    enter: () => console.log("Ready"),
    to: ["loading"]
  })
  .when("loading", {
    enter: async () => {
      console.log("Fetching...");
      await fetchData();
    },
    from: ["idle"],
    to: ["success", "error"]
  })
  .when("success", {
    enter: (ctx) => console.log("Done!", ctx.count),
    from: ["loading"]
  })
  .when("error", {
    from: "*",
    to: ["idle"]
  });

await loader.to("loading");



Define states with .when(). Transition with .to(). The from and to constraints say which transitions are allowed. That is mostly it.

flowchart LR A[".to(state)"] --> B{from/to valid?} B -->|No| C[Blocked] B -->|Yes| D{Guards pass?} D -->|No| C D -->|Yes| E["exit()"] E --> F[Update State] F --> G["enter()"] G --> H[Done]




Guards

Sometimes a transition depends on more than just the current state.

1
loader.can("loading", state => state.context.count < 100);



Now .to("loading") only works if count < 100. Guards stack. If any guard returns false, the transition is blocked.

You can check what transitions are currently valid:

1
loader.transitions(); // ['loading'] or [] depending on guards




Async everything

Enter and exit handlers can be async. Transitions wait for them.

1
2
3
4
5
6
7
8
9
.when("loading", {
  enter: async (ctx) => {
    const data = await fetchData();
    // do something with data
  },
  exit: async () => {
    await cleanup();
  }
})



This is the thing I always had to hack around in other libraries. Here it just works.


Context

State machines often need to carry data. That is what context is for.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const form = machine("step1", { 
  email: "", 
  password: "", 
  isValid: false 
});

// Update context without changing state
form.set({ email: "user@example.com" });

// Update context during transition
await form.to("step2", { isValid: true });

// Or with a function
await form.to("step2", ctx => ({ ...ctx, step: 2 }));



TypeScript infers the context shape. Try to set a field that does not exist and you get a compile error.


Snapshots and history

Sometimes you need to save and restore state. Or go back.

1
2
3
4
5
6
7
8
9
// Snapshot current state
const snapshot = form.snapshot();
// { state: 'step2', context: { email: '...', ... } }

// Later, restore it
form.restore(snapshot);

// Or just go back one step
await form.back();



History is capped at 10 states by default. Enough for undo, not enough to leak memory.


Sequences

For scripted flows, you can run a sequence of transitions:

1
2
3
4
5
await loader.run([
  { to: "loading" },
  { to: "success", delay: 1000 },
  { to: "idle", update: { count: 0 } }
]);



Each step can have a delay. Useful for animations, testing, or anything with timing.

Or use the generator for step-by-step control:

1
2
3
4
const steps = loader.steps();
steps.next(); // initial state
steps.next({ to: "loading" });
steps.next({ to: "success" });




Events

Subscribe to transitions:

1
2
3
4
5
6
7
8
const unsubscribe = loader.on(event => {
  if ('type' in event && event.type === 'transition') {
    console.log(`${event.from}${event.to}`);
    if (event.blocked) {
      console.log(`Blocked by: ${event.blocked}`);
    }
  }
});



You get both successful transitions and blocked attempts. Good for debugging, logging, or wiring up side effects.


What I actually use it for

Form wizards. Multi-step forms where you cannot skip ahead, can go back, and need to validate between steps.

Connection states. WebSocket or API connections with proper handling of connecting, reconnecting, disconnected, error.

UI modes. Modal open/closed, drawer expanded/collapsed, but with rules about what can trigger what.

Game logic. Player states, enemy AI, anything where "what can happen next" depends on "what is happening now".


The tradeoffs

Zero dependencies. Tiny bundle. Works in Node and browsers.

It is not trying to be XState. No visualizers, no actors, no parallel states. If you need those, use XState. If you need a state machine without the ceremony, this is the one I reach for.


PhaseState: https://github.com/Cr0wn-Gh0ul/PhaseState
npm: https://www.npmjs.com/package/phasestate