Picking a React State Library

Four React state libraries put through the same visual designer feature. Redux Toolkit, Zustand, Jotai, XState. What I'd actually pick today and why.

The first cut of the visual app-builder we shipped at the creator platform ran on Redux. Obvious call at the time. Creators dragging blocks around a designer, tree mutating on every drag, undo/redo, ten components reading pieces of that tree without re-rendering the whole canvas. Redux had the DevTools, Redux had the patterns. So Redux it was.

Six weeks in I was writing memoized selector after memoized selector to stop the canvas from flickering during drag. The store was correct. The renders were not. We swapped to Zustand over a long weekend and the flicker went away on Monday. That’s what shaped how I think about this whole category. Picking a state library is not really about features, it’s about the default behavior you get when you stop paying attention.

Same problem for all four below. Checkout flow, four steps, async payment, state survives reload, can’t go back from confirmation. Same shape as half the features I’ve shipped over the years.

Redux Toolkit

RTK is the modern Redux. Slices, immer-backed reducers, createAsyncThunk. The structure is enforced by the library, which is the point if you have a big team.

import { createSlice, configureStore, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';

type Step = 'cart' | 'shipping' | 'payment' | 'confirmation';

interface CheckoutState {
  step: Step;
  shipping: { address: string; city: string; zip: string } | null;
  payment: { status: 'idle' | 'processing' | 'succeeded' | 'failed'; error: string | null };
  orderId: string | null;
}

export const processPayment = createAsyncThunk<string, { amount: number }>(
  'checkout/processPayment',
  async ({ amount }, { rejectWithValue }) => {
    const res = await fetch('/api/payments', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ amount }),
    });
    if (!res.ok) return rejectWithValue(`payment failed: ${res.status}`);
    const body = await res.json();
    return body.orderId as string;
  }
);

const checkoutSlice = createSlice({
  name: 'checkout',
  initialState: {
    step: 'cart',
    shipping: null,
    payment: { status: 'idle', error: null },
    orderId: null,
  } as CheckoutState,
  reducers: {
    setShipping(state, action: PayloadAction<CheckoutState['shipping']>) {
      state.shipping = action.payload;
      state.step = 'payment';
    },
    goToStep(state, action: PayloadAction<Step>) {
      if (state.step === 'confirmation' && action.payload !== 'cart') return;
      state.step = action.payload;
    },
  },
  extraReducers: (b) => {
    b.addCase(processPayment.pending, (s) => { s.payment = { status: 'processing', error: null }; })
     .addCase(processPayment.fulfilled, (s, a) => {
       s.payment = { status: 'succeeded', error: null };
       s.orderId = a.payload;
       s.step = 'confirmation';
     })
     .addCase(processPayment.rejected, (s, a) => {
       s.payment = { status: 'failed', error: (a.payload as string) ?? 'unknown' };
     });
  },
});

export const store = configureStore({ reducer: { checkout: checkoutSlice.reducer } });

What you get: time-travel debugging, predictable structure, RTK Query if you also need server cache. What you pay: a Provider, typed hooks, actions vs thunks vs selectors in your head, and naive useSelector calls that re-render half your tree.

I still reach for RTK when the team is ten engineers or more and convention alone won’t cut it. Below that, it’s ceremony.

Zustand

Zustand is what the visual app-builder runs on today. The mental model is “a store is just a hook.” No Provider, no boilerplate folder, no action types. You define state and the functions that change it.

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: (s: CheckoutStore['shipping']) => void;
  processPayment: (amount: number) => Promise<void>;
  reset: () => void;
}

export const useCheckout = create<CheckoutStore>()(
  persist(
    (set, get) => ({
      step: 'cart',
      shipping: null,
      paymentStatus: 'idle',
      paymentError: null,
      orderId: null,
      setShipping: (data) => set({ shipping: data, step: 'payment' }),
      processPayment: async (amount) => {
        if (get().paymentStatus === 'processing') return;
        set({ paymentStatus: 'processing', paymentError: null });
        try {
          const res = await fetch('/api/payments', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ amount }),
          });
          if (!res.ok) throw new Error(`payment failed: ${res.status}`);
          const { orderId } = await res.json();
          set({ paymentStatus: 'succeeded', orderId, step: 'confirmation' });
        } catch (e) {
          set({ paymentStatus: 'failed', paymentError: (e as Error).message });
        }
      },
      reset: () => set({
        step: 'cart', shipping: null, paymentStatus: 'idle',
        paymentError: null, orderId: null,
      }),
    }),
    { name: 'checkout-v1' }
  )
);

export function PaymentStep() {
  const status = useCheckout((s) => s.paymentStatus);
  const error = useCheckout((s) => s.paymentError);
  const pay = useCheckout((s) => s.processPayment);
  return (
    <button onClick={() => pay(9900)} disabled={status === 'processing'}>
      {status === 'processing' ? 'processing' : 'pay'}
      {error ? <span role="alert">{error}</span> : null}
    </button>
  );
}

That’s the whole state layer. Persistence is one line of middleware. Each useCheckout((s) => s.something) call is its own subscription, so the shipping form doesn’t re-render when the payment status flips. Correct render boundaries by default.

The honest tradeoff: Zustand enforces nothing. Two engineers on the same squad can structure stores completely differently. For the app-builder with three of us, fine. For the community surface on a product I CTO on the side, we wrote a short convention doc and got on with it. For a big team I’d pause before reaching for Zustand without scaffolding.

Jotai

Jotai flips the model. Many tiny atoms, the framework tracks who reads what, derived atoms recompute only when their dependencies change.

import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

type Step = 'cart' | 'shipping' | 'payment' | 'confirmation';

export const stepAtom = atomWithStorage<Step>('checkout-step', 'cart');
export const shippingAtom = atomWithStorage<{
  address: string; city: string; zip: string;
} | null>('checkout-shipping', null);

export const paymentStatusAtom = atom<'idle' | 'processing' | 'succeeded' | 'failed'>('idle');
export const paymentErrorAtom = atom<string | null>(null);
export const orderIdAtom = atom<string | null>(null);

export const isCompleteAtom = atom(
  (get) => get(paymentStatusAtom) === 'succeeded' && get(orderIdAtom) !== null
);

export const summaryAtom = atom((get) => ({
  shipping: get(shippingAtom),
  orderId: get(orderIdAtom),
  isComplete: get(isCompleteAtom),
}));

export const processPaymentAtom = atom(null, async (get, set, amount: number) => {
  if (get(paymentStatusAtom) === 'processing') return;
  set(paymentStatusAtom, 'processing');
  set(paymentErrorAtom, null);
  try {
    const res = await fetch('/api/payments', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ amount }),
    });
    if (!res.ok) throw new Error(`payment failed: ${res.status}`);
    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);
  }
});

When state decomposes into many small pieces with derivation between them, Jotai is great. A filter panel where field B depends on A which depends on a validation of C. We didn’t pick it for the visual app-builder because discoverability gets hard fast. In Zustand or Redux you open one file and see the whole checkout state. In Jotai you open ten files and chase the graph in your head.

XState

XState is not really competing with the other three. It models behavior, not data. A state machine where transitions are explicit and impossible states are impossible.

import { setup, assign, fromPromise } from 'xstate';

const processPayment = fromPromise(async ({ input }: { input: { amount: number } }) => {
  const res = await fetch('/api/payments', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ amount: input.amount }),
  });
  if (!res.ok) throw new Error(`payment failed: ${res.status}`);
  return (await res.json()).orderId as string;
});

export 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: 'shipping' } },
    shipping: {
      on: {
        SUBMIT_SHIPPING: {
          target: 'payment',
          actions: assign({ shipping: ({ event }) => event.data }),
        },
        BACK: 'cart',
      },
    },
    payment: {
      on: { SUBMIT_PAYMENT: 'processing', BACK: 'shipping' },
    },
    processing: {
      invoke: {
        src: 'processPayment',
        input: ({ event }) =>
          event.type === 'SUBMIT_PAYMENT' ? { amount: event.amount } : { 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 }) },
  },
});

You cannot dispatch SUBMIT_PAYMENT from the cart state. There is no path back from confirmation. That contract is the whole pitch. The Stately visual editor lets a PM look at the diagram and say “we need a review step between payment and confirmation” without anyone opening a code file. I’ve used XState for the upload-with-retry flow inside the teacher dashboard of an AI music-learning product I’m building. Audio uploads, transcoding, retries on backoff. Sweet spot.

Cost: learning curve, ~20 KB gzipped. For a modal toggle, wrong tool.

Undo/redo in the visual app-builder

The visual app-builder we shipped at the creator platform held up in production. State model is a tree of nodes with derived computed-layout state on top.

Early build on Redux, muscle memory. Undo/redo on Redux is genuinely nice. Three arrays of slice snapshots, past, present, future. What killed us was render granularity. Every keystroke in a property panel was a dispatch, which re-rendered every subscribed component unless you wrote memoized selectors per leaf. Canvas flickered during drag. First wrong fix was wrapping every leaf in React.memo with deep-equality. CPU went up, flicker went down a bit, the inspector stopped feeling crisp.

Rewrote the state layer on Zustand with a hand-rolled history middleware. Each node, its own subscription. Snapshot on every committed mutation, throttled so a drag became one history entry instead of two hundred. Flickering stopped. Lesson: when render boundaries are the bottleneck, the library’s default subscription model matters more than its API surface.

What I’d pick

Zustand first, for most React apps. Pair it with TanStack Query for server state and you’ve covered most apps.

XState when the feature is explicitly a state machine with guarded transitions. Multi-step uploads. Onboarding with branching. Anything where “what events are valid from here” is a real product question.

RTK when the team is big enough that enforced structure beats minimalism, or when RTK Query fits better than TanStack Query. DevTools alone aren’t a reason.

Jotai I don’t reach for as the primary store. Use it for one feature inside a bigger app when state genuinely decomposes into a derivation graph.

Takeaways

  • Zustand is the right default for new React apps. Correct render boundaries by default.
  • XState earns its weight when behavior is explicitly a state machine. Not for storing data.
  • RTK is for teams big enough that enforced structure pays for itself.
  • Jotai is a sharp tool for derivation graphs. Feature-scoped, not app-wide.
  • Pair any client store with TanStack Query for server state.
  • Render granularity beats API ergonomics. Pick the library whose default behavior matches your hot path.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.