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.
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.
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.
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:
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.
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);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:
// 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;
});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.
// 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.
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
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 viastore.send({ type }).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.createAtomhandles 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
- XState Store Documentation -- Stately: The official docs for
createStoreandcreateAtom, including React integration, TypeScript types, and migration guides. - Zustand Documentation: If you prefer Zustand's API, the
useStore(store, selector)pattern is nearly identical touseSelector. The concepts transfer directly. - XState Store Overview -- David Khourshid: A walkthrough of XState Store's design goals. Note: verify this YouTube link before publishing.
Keep reading