A proof-of-concept exploring how to combine XState (formal state machines), Zustand (lightweight global state), and Immer (ergonomic immutability) in the same React application.

Problem

Different state problems need different tools:

  • XState: Complex workflows, explicit transitions, parallel states
  • Zustand: Simple global state, derived values, subscriptions
  • Immer: Ergonomic immutable updates without spread hell

But combining them creates friction. XState v5 actors misbehave in React Strict Mode. Debugging requires juggling multiple devtools. When should you reach for which?

Architecture

┌─────────────────────────────────────────────────────────┐
│                     React Components                      │
├──────────────────────┬──────────────────────────────────┤
│   useStrictSafeMachine   │        useCounterStore        │
│   (XState wrapper)       │        (Zustand hook)         │
├──────────────────────┴──────────────────────────────────┤
│                   Unified Inspector                       │
│              (Console-based devtool)                      │
└─────────────────────────────────────────────────────────┘

Zustand (counterStore.ts): Simple numeric state with history tracking

XState (toggleMachine.ts): Toggle state machine with transition logging

Bridge (useStrictSafeMachine.ts): Custom hook ensuring XState actors behave in Strict Mode

Key Pattern: Strict Mode Safety

React Strict Mode double-invokes effects in development. XState actors don’t expect this—they start twice, creating duplicate state. The solution:

// useStrictSafeMachine.ts
const actorRef = useRef<ActorRef | null>(null);

useEffect(() => {
  if (!actorRef.current) {
    actorRef.current = createActor(machine);
    actorRef.current.start();
  }
  return () => {
    // Only stop if actually unmounting (not Strict Mode re-run)
  };
}, []);

Immutability with Immer

XState v5 doesn’t include @xstate/immer (that’s v4 only). Manual integration:

actions: assign(({ context }) =>
  produce(context, (draft) => {
    draft.isEnabled = true;
    draft.toggleCount += 1;
  })
)

Zustand’s immer middleware makes it automatic:

immer((set) => ({
  increment: () => set((state) => { state.count += 1 })
}))

Console Inspector

A unified devtool logs both XState and Zustand state changes:

[XState Event] TOGGLE
Actor: x:1
State: active
Context: {"isEnabled":true,"toggleCount":1}

[Zustand] count changed: 0 → 1

Works in browser DevTools. No external services or WebSocket connections.

Current Status

When to Use Which

Problem TypeTool
Complex workflows, explicit statesXState
Simple global state, UI togglesZustand
Nested object updatesImmer (with either)

Why This Exists

State management debates often assume you pick one. This exploration asks: what if you use each tool for what it’s best at? The answer: it works, but you need glue code (Strict Mode safety, unified debugging) that doesn’t exist out of the box.