XState Store: Event-Driven State in React

XState Store brings the event-driven model to React state without the full weight of state machines. You define transitions declaratively, select slices to avoid unnecessary re-renders, and dispatch events instead of setting values.

June 7, 20265 min read2 / 2

XState Store is the practical answer to the question: what if I want the event-driven model from useReducer, but without manually wiring up context, writing selector hooks, or managing a provider?

It supports both stores and atoms in one package. The concepts apply directly to Zustand, Redux Toolkit, or any store-based library -- the API is different, the mental model is the same.

Creating a Store

A store has two parts: the initial state (called context in XState Store) and the transitions that can change it.

TypeScript
import { createStore } from '@xstate/store'; type CartContext = { items: CartItem[]; status: 'idle' | 'submitting' | 'confirmed'; }; const cartStore = createStore({ context: { items: [] as CartItem[], status: 'idle' as const, }, on: { itemAdded: (context, event: { item: CartItem }) => ({ ...context, items: [...context.items, event.item] }), itemRemoved: (context, event: { itemId: string }) => ({ ...context, items: context.items.filter(i => i.id !== event.itemId) }), checkoutStarted: (context) => ({ ...context, status: 'submitting' }), checkoutConfirmed: (context) => ({ ...context, status: 'confirmed', items: [] }), }, });

The transitions are pure functions -- the same shape as a reducer case. XState Store infers the event types from the transition definitions. No manual union type needed.

Selective Subscriptions with useSelector

This is the reason to use a store over context. useSelector lets a component subscribe to exactly the data it needs. Re-renders only happen when that specific data changes.

TSX
import { useSelector } from '@xstate/store/react'; function CartBadge() { // Re-renders only when item count changes const count = useSelector(cartStore, s => s.context.items.length); return <span className="badge">{count}</span>; } function CartTotal() { // Re-renders only when items array changes const total = useSelector( cartStore, s => s.context.items.reduce((sum, item) => sum + item.price, 0) ); return <p>Total: ${total.toFixed(2)}</p>; } function CheckoutButton() { // Re-renders only when status changes const status = useSelector(cartStore, s => s.context.status); return ( <button disabled={status === 'submitting'} onClick={() => cartStore.send({ type: 'checkoutStarted' })} > {status === 'submitting' ? 'Processing...' : 'Checkout'} </button> ); }

CartBadge, CartTotal, and CheckoutButton all read from the same store. An update to status does not re-render CartBadge. An update to items does not re-render CheckoutButton.

Compare this to context, where any state change re-renders every consumer.

Sending events is direct -- no dispatcher, no provider:

TypeScript
cartStore.send({ type: 'itemAdded', item: { id: 'p1', name: 'Keyboard', price: 149 } }); cartStore.send({ type: 'itemRemoved', itemId: 'p1' });

Standalone Atoms

For state that does not fit the controlled-transition model -- an external value, a real-time signal -- XState Store has atoms.

TypeScript
import { createAtom } from '@xstate/store'; // External signal (e.g., from a WebSocket or polling interval) const flashSaleAtom = createAtom(false); // Update it from anywhere setInterval(() => { flashSaleAtom.set(Math.random() > 0.5); }, 3000);
TSX
function PricingBanner() { const onSale = useSelector(flashSaleAtom, s => s); return onSale ? <div className="banner">Flash sale -- 50% off!</div> : null; }

Atoms are reactive. You can derive a new atom from multiple sources:

TypeScript
// Recomputes automatically when cartStore or flashSaleAtom updates const totalWithDiscountAtom = createAtom((get) => { const items = get(cartStore).context.items; const onSale = get(flashSaleAtom); const base = items.reduce((sum, i) => sum + i.price, 0); return onSale ? base * 0.5 : base; });
TSX
function FinalPrice() { // Subscribes to both cartStore and flashSaleAtom through the derived atom const total = useSelector(totalWithDiscountAtom, s => s); return <p>You pay: ${total.toFixed(2)}</p>; }

FinalPrice re-renders exactly when either the cart items or the flash sale status changes. The subscriptions are declared once in the atom derivation -- not repeated in every component.

Refactoring from useReducer + Context

The pattern when migrating is the same one used throughout this series: work side by side, verify they match, then delete the old code.

TypeScript
// 1. Create the store (mirrors your existing reducer + initial state) const bookingStore = createStore({ context: initialBookingState, on: { searchSubmitted: (context, event: { query: string }) => ({ ...context, status: 'loading', query: event.query }), resultsReceived: (context, event: { results: Course[] }) => ({ ...context, status: 'results', results: event.results }), backPressed: (context) => context.status === 'results' ? { ...context, status: 'idle' } : context, }, }); // 2. Convenience hook (same interface as before) function useBookingState() { return useSelector(bookingStore, s => s.context); } // 3. In components: replace dispatch with store.send // Replace useContext(BookingCtx) with useSelector(bookingStore, ...)

Once you have both running in parallel and the console logs confirm the states match, delete the useReducer, createContext, and BookingProvider. The context boilerplate disappears entirely.

This pattern is the same whether you use Redux Toolkit, Zustand, or XState Store. You are replacing the useReducer + useContext dance with a store that handles the subscriptions internally.

Data flow diagram: multiple components each call useSelector with different selectors pointing into the same store, arrows show that only the component whose selected value changed triggers a re-render when an event is dispatched ExpandData flow diagram: multiple components each call useSelector with different selectors pointing into the same store, arrows show that only the component whose selected value changed triggers a re-render when an event is dispatched

The Essentials

  1. createStore({ context, on }) is a reducer container with type inference. Transitions are pure functions that take context + event and return new context. No manual type unions. Events dispatch via store.send({ type }).
  2. useSelector(store, selector) gives components exactly what they need. The component re-renders only when the selected value changes -- not when any part of the store updates. This is the selective subscription advantage over context.
  3. createAtom handles reactive, external state. Derived atoms recompute and re-render automatically when their dependencies update. Use them for WebSocket signals, timers, or any value that comes from outside the app's event model.

Further Reading and Watching