xstate-immer-zustand
Exploring hybrid state management in React
Proof-of-concept combining XState state machines with Zustand stores and Immer immutability
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 Type | Tool |
|---|---|
| Complex workflows, explicit states | XState |
| Simple global state, UI toggles | Zustand |
| Nested object updates | Immer (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.