Most state management comparisons list features side by side and call it a day. That is not very useful when you are staring at a new project and trying to decide which library to pull in. The differences only become visible when you build the same feature with each one.
This article implements a multi-step checkout flow — cart, shipping, payment, confirmation — with Redux Toolkit, Zustand, Jotai, and XState. Same requirements, same UI, four different mental models. Along the way, we will measure what actually matters: bundle cost, re-render behavior, and how each library handles the inevitable “we need to add one more step” requirement change.
The checkout flow has four steps. Each step collects data. Steps can be navigated forward and backward. The payment step talks to an async API. The confirmation step summarizes everything. If the user leaves mid-flow and comes back, the state should persist.
This is a realistic feature that exercises every library’s strengths and weaknesses: shared state across components, derived state, async operations, state persistence, and complex transitions.
RTK’s mental model is straightforward — one store, slices own their state, reducers describe transitions. For a checkout flow, the structure looks natural:
import { createSlice, configureStore, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface CheckoutState {
step: 'cart' | 'shipping' | 'payment' | 'confirmation';
shipping: { address: string; city: string; zip: string } | null;
payment: { status: 'idle' | 'processing' | 'succeeded' | 'failed'; error: string | null };
orderId: string | null;
}
const processPayment = createAsyncThunk(
'checkout/processPayment',
async (amount: number) => {
const res = await fetch('/api/payments', {
method: 'POST',
body: JSON.stringify({ amount }),
});
if (!res.ok) throw new Error('Payment failed');
return (await res.json()).orderId as string;
}
);
const checkoutSlice = createSlice({
name: 'checkout',
initialState: {
step: 'cart',
shipping: null,
payment: { status: 'idle', error: null },
orderId: null,
} as CheckoutState,
reducers: {
goToStep(state, action: PayloadAction<CheckoutState['step']>) {
state.step = action.payload;
},
setShipping(state, action: PayloadAction<CheckoutState['shipping']>) {
state.shipping = action.payload;
state.step = 'payment';
},
},
extraReducers: (builder) => {
builder
.addCase(processPayment.pending, (state) => {
state.payment = { status: 'processing', error: null };
})
.addCase(processPayment.fulfilled, (state, action) => {
state.payment = { status: 'succeeded', error: null };
state.orderId = action.payload;
state.step = 'confirmation';
})
.addCase(processPayment.rejected, (state, action) => {
state.payment = { status: 'failed', error: action.error.message ?? 'Unknown error' };
});
},
});
The async thunk handles payment processing, and extraReducers maps each promise state to a UI state. This is where RTK earns its reputation — the pattern is explicit, every state transition is visible in one place, and Redux DevTools lets you replay the entire checkout flow action by action.
The friction: even with RTK’s reduced boilerplate, you still need a Provider at the root, typed hooks (useAppDispatch, useAppSelector), and the mental overhead of actions vs thunks vs selectors. For a checkout flow, this is fine. For a tooltip’s open/close state, it is ceremony without payoff.
If the checkout also needs to fetch shipping options or validate addresses, RTK Query eliminates the manual loading/error/caching pattern:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
const checkoutApi = createApi({
reducerPath: 'checkoutApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getShippingOptions: builder.query<ShippingOption[], string>({
query: (zip) => `shipping-options?zip=${zip}`,
}),
validateAddress: builder.mutation<boolean, Address>({
query: (address) => ({ url: 'validate-address', method: 'POST', body: address }),
}),
}),
});
export const { useGetShippingOptionsQuery, useValidateAddressMutation } = checkoutApi;
RTK Query’s cache invalidation and automatic refetching are genuinely hard to replicate by hand. If your app already uses Redux and needs server state caching, RTK Query is a strong reason to stay in the ecosystem rather than adding TanStack Query alongside it.
Zustand’s pitch is “what if a store was just a hook?” The same checkout flow, stripped to its essentials:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Step = 'cart' | 'shipping' | 'payment' | 'confirmation';
interface CheckoutStore {
step: Step;
shipping: { address: string; city: string; zip: string } | null;
paymentStatus: 'idle' | 'processing' | 'succeeded' | 'failed';
paymentError: string | null;
orderId: string | null;
setShipping: (data: CheckoutStore['shipping']) => void;
processPayment: (amount: number) => Promise<void>;
goToStep: (step: Step) => void;
reset: () => void;
}
const useCheckoutStore = create<CheckoutStore>()(
persist(
(set) => ({
step: 'cart',
shipping: null,
paymentStatus: 'idle',
paymentError: null,
orderId: null,
setShipping: (data) => set({ shipping: data, step: 'payment' }),
processPayment: async (amount) => {
set({ paymentStatus: 'processing', paymentError: null });
try {
const res = await fetch('/api/payments', {
method: 'POST',
body: JSON.stringify({ amount }),
});
if (!res.ok) throw new Error('Payment failed');
const { orderId } = await res.json();
set({ paymentStatus: 'succeeded', orderId, step: 'confirmation' });
} catch (e) {
set({ paymentStatus: 'failed', paymentError: (e as Error).message });
}
},
goToStep: (step) => set({ step }),
reset: () => set({
step: 'cart', shipping: null, paymentStatus: 'idle',
paymentError: null, orderId: null,
}),
}),
{ name: 'checkout-storage' }
)
);
That is the entire state layer. No provider, no boilerplate files, no action types. The persist middleware gives us the “resume where you left off” requirement in one line.
Using it in components is where Zustand’s selector model pays off:
function PaymentStep() {
const status = useCheckoutStore((s) => s.paymentStatus);
const error = useCheckoutStore((s) => s.paymentError);
const processPayment = useCheckoutStore((s) => s.processPayment);
// This component only re-renders when status, error, or processPayment changes.
// Changes to shipping, step, or orderId do not trigger a re-render here.
}
Each selector subscription is independent. If the shipping step updates its data, the payment step does not re-render. This is Zustand’s default behavior — no React.memo, no shallowEqual, no createSelector. You get correct re-render boundaries by writing natural selectors.
The tradeoff: Zustand does not enforce structure. Two developers on the same team might organize stores completely differently. There is no middleware ecosystem comparable to Redux’s saga/thunk/observable pipeline. For the checkout flow, this is not a problem. For an app with 50 stores, you need team conventions that Zustand will not enforce for you.
Jotai inverts the model. Instead of one store that holds everything, you start with small atoms and compose upward. The checkout flow becomes a set of independent pieces:
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
type Step = 'cart' | 'shipping' | 'payment' | 'confirmation';
// Each piece of state is an independent atom
const stepAtom = atomWithStorage<Step>('checkout-step', 'cart');
const shippingAtom = atomWithStorage<{
address: string; city: string; zip: string;
} | null>('checkout-shipping', null);
const paymentStatusAtom = atom<'idle' | 'processing' | 'succeeded' | 'failed'>('idle');
const paymentErrorAtom = atom<string | null>(null);
const orderIdAtom = atom<string | null>(null);
// Derived atom: is the checkout complete?
const isCheckoutCompleteAtom = atom((get) => {
return get(paymentStatusAtom) === 'succeeded' && get(orderIdAtom) !== null;
});
// Derived atom: summary for confirmation page
const checkoutSummaryAtom = atom((get) => ({
shipping: get(shippingAtom),
orderId: get(orderIdAtom),
isComplete: get(isCheckoutCompleteAtom),
}));
// Write atom: encapsulates the payment side effect
const processPaymentAtom = atom(null, async (get, set, amount: number) => {
set(paymentStatusAtom, 'processing');
set(paymentErrorAtom, null);
try {
const res = await fetch('/api/payments', {
method: 'POST',
body: JSON.stringify({ amount }),
});
if (!res.ok) throw new Error('Payment failed');
const { orderId } = await res.json();
set(paymentStatusAtom, 'succeeded');
set(orderIdAtom, orderId);
set(stepAtom, 'confirmation');
} catch (e) {
set(paymentStatusAtom, 'failed');
set(paymentErrorAtom, (e as Error).message);
}
});
The power shows in derived atoms. checkoutSummaryAtom automatically recomputes when any of its dependencies change. You do not manually wire up selectors — Jotai tracks the dependency graph. Components that read checkoutSummaryAtom only re-render when the summary actually changes, not when unrelated atoms update.
import { useAtomValue, useSetAtom } from 'jotai';
function ConfirmationStep() {
const summary = useAtomValue(checkoutSummaryAtom);
// Re-renders only when shipping, orderId, or isComplete changes.
// paymentStatus changes during processing do not affect this component.
}
function PaymentButton({ amount }: { amount: number }) {
const processPayment = useSetAtom(processPaymentAtom);
const status = useAtomValue(paymentStatusAtom);
return (
<button onClick={() => processPayment(amount)} disabled={status === 'processing'}>
{status === 'processing' ? 'Processing...' : 'Pay Now'}
</button>
);
}
Where Jotai excels: when state naturally decomposes into many small pieces with derivation relationships. A spreadsheet-like UI, a complex filter panel, or a form where fields depend on each other — these are Jotai’s sweet spot.
Where it hurts: discoverability. In Redux or Zustand, you open one file and see the entire checkout state. In Jotai, atoms can be scattered across multiple files. As the atom count grows, understanding “what depends on what” requires either strict file organization or the Jotai DevTools extension. The mental model shift from “one store” to “atom graph” is real and takes time.
XState takes a fundamentally different approach. Instead of storing values and mutating them, you model the system as states and transitions. The checkout flow is not “a bag of data that changes” — it is “a machine that moves between well-defined states”:
import { setup, assign, fromPromise } from 'xstate';
const processPayment = fromPromise(async ({ input }: { input: { amount: number } }) => {
const res = await fetch('/api/payments', {
method: 'POST',
body: JSON.stringify({ amount: input.amount }),
});
if (!res.ok) throw new Error('Payment failed');
return (await res.json()).orderId as string;
});
const checkoutMachine = setup({
types: {
context: {} as {
shipping: { address: string; city: string; zip: string } | null;
orderId: string | null;
error: string | null;
},
events: {} as
| { type: 'SUBMIT_SHIPPING'; data: { address: string; city: string; zip: string } }
| { type: 'SUBMIT_PAYMENT'; amount: number }
| { type: 'BACK' }
| { type: 'RESET' },
},
actors: { processPayment },
}).createMachine({
id: 'checkout',
initial: 'cart',
context: { shipping: null, orderId: null, error: null },
states: {
cart: {
on: { SUBMIT_SHIPPING: { target: 'shipping' } },
},
shipping: {
on: {
SUBMIT_SHIPPING: {
target: 'payment',
actions: assign({ shipping: ({ event }) => event.data }),
},
BACK: { target: 'cart' },
},
},
payment: {
on: {
SUBMIT_PAYMENT: { target: 'processing' },
BACK: { target: 'shipping' },
},
},
processing: {
invoke: {
src: 'processPayment',
input: ({ event }) => {
if (event.type === 'SUBMIT_PAYMENT') return { amount: event.amount };
return { amount: 0 };
},
onDone: {
target: 'confirmation',
actions: assign({ orderId: ({ event }) => event.output, error: null }),
},
onError: {
target: 'payment',
actions: assign({ error: ({ event }) => (event.error as Error).message }),
},
},
},
confirmation: {
type: 'final',
},
},
on: {
RESET: { target: '.cart', actions: assign({ shipping: null, orderId: null, error: null }) },
},
});
This is more code, but it buys you something the other libraries cannot express: impossible states are impossible. You cannot submit a payment from the cart step. You cannot go back from the confirmation step. The machine definition is a contract — if a transition is not in the config, it cannot happen.
import { useMachine } from '@xstate/react';
function Checkout() {
const [state, send] = useMachine(checkoutMachine);
switch (true) {
case state.matches('cart'):
return <CartStep onNext={() => send({ type: 'SUBMIT_SHIPPING' })} />;
case state.matches('shipping'):
return (
<ShippingStep
onSubmit={(data) => send({ type: 'SUBMIT_SHIPPING', data })}
onBack={() => send({ type: 'BACK' })}
/>
);
case state.matches('payment'):
return (
<PaymentStep
error={state.context.error}
onSubmit={(amount) => send({ type: 'SUBMIT_PAYMENT', amount })}
onBack={() => send({ type: 'BACK' })}
/>
);
case state.matches('processing'):
return <LoadingSpinner />;
case state.matches('confirmation'):
return <ConfirmationStep orderId={state.context.orderId!} />;
}
}
The payoff: XState’s Stately visual editor renders the machine as an interactive diagram. Product managers can look at it and say “wait, we need a review step between payment and confirmation.” You add a state, wire the transitions, and the feature is specified. This communication value is unique among state management tools.
The cost: the learning curve is steep. State machines, actors, guards, and invoked services are concepts most frontend developers have not worked with. The bundle size is larger (~20KB gzipped for xstate + @xstate/react). And for simple state — a modal toggle, a counter, a theme preference — XState is dramatically overengineered.
| Library | Core | React bindings | Total |
|---|---|---|---|
| Zustand | 1.1 KB | included | 1.1 KB |
| Jotai | 2.4 KB | included | 2.4 KB |
| Redux Toolkit | 11 KB | 2 KB (react-redux) | 13 KB |
| XState | 15 KB | 5 KB (@xstate/react) | 20 KB |
Zustand is 18x smaller than XState. For an app that ships a single checkout page, this matters. For a large SPA that already ships 500KB of JavaScript, the difference is negligible.
Tested with React DevTools Profiler — updating the shipping address and measuring which components re-render:
| Library | Components re-rendered on shipping update |
|---|---|
Redux Toolkit (naive useSelector) | CartStep, ShippingStep, PaymentStep |
| Redux Toolkit (granular selectors) | ShippingStep only |
| Zustand (with selectors) | ShippingStep only |
| Jotai (atom per field) | ShippingStep only |
| XState | Checkout (parent), ShippingStep |
Redux re-renders depend entirely on how you write selectors. A useSelector(state => state.checkout) grabs the whole slice and re-renders on every change. Zustand and Jotai give you granular subscriptions by default — you have to try to get it wrong. XState re-renders the component that calls useMachine, so the parent component always re-renders on state transitions.
devtools middleware, shows state snapshots in Redux DevTools. No time-travel.jotai-devtools package shows atom values and dependency graphs. Useful but less mature.This is not about which library is “best.” It is about matching the tool to the problem:
Zustand when you need shared client state with minimal ceremony. Shopping cart, UI preferences, notification queue, sidebar state. It is the right default for most React apps in 2026 — especially since React Server Components handle server data, leaving only genuine client state for a store.
Jotai when state is naturally atomic and heavily derived. Complex filter UIs, spreadsheet-like interfaces, forms where field B depends on field A which depends on a remote validation of field C. The dependency graph model eliminates manual memoization.
Redux Toolkit when the team is large (10+ developers) and needs enforced conventions, or when RTK Query’s server-state caching fits the architecture. Also when you need an audit trail of every state change for debugging or compliance.
XState when the logic has explicit states with guarded transitions — multi-step wizards, WebSocket connection lifecycle, retry/backoff flows, or any feature where “what transitions are valid from this state?” is a critical question. Not for storing data, but for modeling behavior.
These are not mutually exclusive. A practical architecture in 2026:
This keeps each tool in its sweet spot and avoids forcing any single library to handle problems it was not designed for.
If you are moving away from Redux, here is a pragmatic approach:
Redux → Zustand: Map each slice to a Zustand store. Redux selectors translate almost directly to Zustand selectors. The biggest change is moving from dispatching actions to calling store methods directly. Migrate one slice at a time — both can coexist.
// Redux
dispatch(setShipping(data));
const shipping = useSelector((state) => state.checkout.shipping);
// Zustand
useCheckoutStore.getState().setShipping(data);
const shipping = useCheckoutStore((s) => s.shipping);
Redux → Jotai: Break each slice field into an atom. Derived selectors become derived atoms. This is a bigger mental shift — you are decomposing a centralized store into a distributed atom graph. Start with one feature, not the entire app.
Any → XState: XState typically does not replace your data store. It replaces your useState + useEffect state machine that you did not realize was a state machine. Look for components with multiple boolean flags (isLoading, isError, isRetrying, hasSubmitted) — those are implicit state machines waiting to be made explicit.