React State Management: Redux Toolkit, Zustand and React Query (2026)

State management is the decision that shapes every React application. Pick the wrong tool and you end up fighting boilerplate on simple tasks or lacking structure on complex ones. In 2026 the ecosystem has stabilized around a clear mental model: local state stays in components, global client state lives in Zustand or Redux Toolkit, and server state is owned by TanStack Query. This guide covers all three layers with production-ready code.

Table of Contents

The Three Categories of State

Before reaching for any library, classify your state. The wrong classification leads to over-engineering or re-fetching bugs.

  • Local state — data that belongs to one component and its children. A modal open/closed flag, a form field value, a hover state. Use useState or useReducer.
  • Global client state — data shared across many unrelated components that has no server equivalent. User preferences, shopping cart, theme, UI wizard step. Use Zustand or Redux Toolkit.
  • Server state — data that lives on a server and is fetched asynchronously. User profiles, product lists, order history. This data has cache, stale, loading, and error states. Use TanStack Query.
Note: A common mistake is storing server data in Redux. You then have to manually manage loading, error, refetching, and cache invalidation — all things TanStack Query handles automatically. Keep server state out of your global store.

Redux Toolkit: createSlice and RTK Query

Redux Toolkit (RTK) is the official, opinionated way to write Redux. It eliminates the old boilerplate of action types, action creators, and switch statements. For large teams with complex client state, it remains the gold standard.

createSlice — Define State, Reducers, and Actions Together

// store/cartSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  couponCode: string | null;
}

const initialState: CartState = {
  items: [],
  couponCode: null,
};

export const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addItem(state, action: PayloadAction<CartItem>) {
      const existing = state.items.find(i => i.id === action.payload.id);
      if (existing) {
        existing.quantity += action.payload.quantity;
      } else {
        state.items.push(action.payload);
      }
    },
    removeItem(state, action: PayloadAction<string>) {
      state.items = state.items.filter(i => i.id !== action.payload);
    },
    applyCoupon(state, action: PayloadAction<string>) {
      state.couponCode = action.payload;
    },
    clearCart(state) {
      state.items = [];
      state.couponCode = null;
    },
  },
});

export const { addItem, removeItem, applyCoupon, clearCart } = cartSlice.actions;
export default cartSlice.reducer;

createAsyncThunk — Async Operations

// store/ordersSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const fetchOrders = createAsyncThunk(
  'orders/fetchAll',
  async (userId: string, { rejectWithValue }) => {
    try {
      const res = await fetch(`/api/orders?userId=${userId}`);
      if (!res.ok) throw new Error('Network response was not ok');
      return await res.json();
    } catch (err: any) {
      return rejectWithValue(err.message);
    }
  }
);

const ordersSlice = createSlice({
  name: 'orders',
  initialState: { data: [], loading: false, error: null as string | null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchOrders.pending, (state) => { state.loading = true; state.error = null; })
      .addCase(fetchOrders.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      .addCase(fetchOrders.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload as string;
      });
  },
});

export default ordersSlice.reducer;
Pro Tip: RTK uses Immer under the hood, so you can write mutating logic like state.items.push(item) inside reducers — Immer converts it to an immutable update automatically.

Configuring the Store

// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';
import ordersReducer from './ordersSlice';
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';

export const store = configureStore({
  reducer: {
    cart: cartReducer,
    orders: ordersReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

// Typed hooks — use these instead of plain useDispatch/useSelector
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Zustand: Lightweight Global Store

Zustand is a minimal state manager built on React hooks. No providers, no boilerplate, no reducers. For most apps without a large team, Zustand handles global client state better than Redux Toolkit at a fraction of the complexity cost.

Shopping Cart with Zustand

// store/useCartStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  totalPrice: () => number;
}

export const useCartStore = create<CartStore>()(
  devtools(
    persist(
      (set, get) => ({
        items: [],

        addItem: (item) =>
          set((state) => {
            const existing = state.items.find((i) => i.id === item.id);
            if (existing) {
              return {
                items: state.items.map((i) =>
                  i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i
                ),
              };
            }
            return { items: [...state.items, item] };
          }),

        removeItem: (id) =>
          set((state) => ({ items: state.items.filter((i) => i.id !== id) })),

        updateQuantity: (id, quantity) =>
          set((state) => ({
            items: state.items.map((i) => (i.id === id ? { ...i, quantity } : i)),
          })),

        clearCart: () => set({ items: [] }),

        totalPrice: () =>
          get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
      }),
      { name: 'cart-storage' } // persists to localStorage
    )
  )
);

Using the Store in Components

// components/CartButton.tsx
import { useCartStore } from '../store/useCartStore';

export function CartButton({ product }: { product: { id: string; name: string; price: number } }) {
  const addItem = useCartStore((state) => state.addItem);
  const itemCount = useCartStore((state) => state.items.length);

  return (
    <div>
      <button onClick={() => addItem({ ...product, quantity: 1 })}>
        Add to Cart ({itemCount})
      </button>
    </div>
  );
}

// components/CartSummary.tsx
export function CartSummary() {
  // Only subscribe to what you need — prevents unnecessary re-renders
  const items = useCartStore((state) => state.items);
  const totalPrice = useCartStore((state) => state.totalPrice);
  const removeItem = useCartStore((state) => state.removeItem);

  return (
    <div>
      {items.map((item) => (
        <div key={item.id}>
          <span>{item.name} × {item.quantity}</span>
          <span>${(item.price * item.quantity).toFixed(2)}</span>
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}
      <strong>Total: ${totalPrice().toFixed(2)}</strong>
    </div>
  );
}
Pro Tip: Always use selector functions with Zustand: useCartStore(state => state.items) not useCartStore(). Subscribing to the whole store causes a re-render on any state change, defeating the point.

TanStack Query v5: Server State Done Right

TanStack Query (formerly React Query) v5 manages the full lifecycle of server data — fetching, caching, background revalidation, pagination, and optimistic updates. You write almost no loading/error state manually.

Setup

// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // data is fresh for 5 minutes
      retry: 2,
    },
  },
});

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

useQuery and useMutation

// hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

interface Product { id: string; name: string; price: number; stock: number; }

// Query keys as constants — prevents typos
export const productKeys = {
  all: ['products'] as const,
  list: (filters: object) => [...productKeys.all, 'list', filters] as const,
  detail: (id: string) => [...productKeys.all, 'detail', id] as const,
};

export function useProducts(filters = {}) {
  return useQuery({
    queryKey: productKeys.list(filters),
    queryFn: async () => {
      const res = await fetch(`/api/products?${new URLSearchParams(filters as any)}`);
      if (!res.ok) throw new Error('Failed to fetch products');
      return res.json() as Promise<Product[]>;
    },
    staleTime: 1000 * 60 * 2, // 2 minutes
  });
}

export function useUpdateProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ id, data }: { id: string; data: Partial<Product> }) => {
      const res = await fetch(`/api/products/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });
      if (!res.ok) throw new Error('Update failed');
      return res.json();
    },
    // Optimistic update
    onMutate: async ({ id, data }) => {
      await queryClient.cancelQueries({ queryKey: productKeys.all });
      const previous = queryClient.getQueryData(productKeys.detail(id));
      queryClient.setQueryData(productKeys.detail(id), (old: Product) => ({ ...old, ...data }));
      return { previous };
    },
    onError: (err, { id }, context) => {
      // Roll back on error
      queryClient.setQueryData(productKeys.detail(id), context?.previous);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: productKeys.all });
    },
  });
}

// Usage in component
function ProductList() {
  const { data: products, isLoading, error } = useProducts({ category: 'electronics' });
  const updateProduct = useUpdateProduct();

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <ul>
      {products?.map((p) => (
        <li key={p.id}>
          {p.name}
          <button onClick={() => updateProduct.mutate({ id: p.id, data: { stock: p.stock - 1 } })}>
            Decrease stock
          </button>
        </li>
      ))}
    </ul>
  );
}

Jotai: Atomic State

Jotai takes a bottom-up approach: you define individual atoms and derive composed state from them. There's no single store — atoms are created on demand and garbage-collected when unused. It's ideal for fine-grained reactivity.

// atoms/themeAtoms.ts
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// Persisted atom — reads/writes to localStorage
export const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'dark');

// Derived (read-only) atom
export const isDarkAtom = atom((get) => get(themeAtom) === 'dark');

// Write-only atom
export const toggleThemeAtom = atom(null, (get, set) => {
  set(themeAtom, get(themeAtom) === 'dark' ? 'light' : 'dark');
});

// Component
function ThemeToggle() {
  const isDark = useAtomValue(isDarkAtom);
  const toggleTheme = useSetAtom(toggleThemeAtom);
  return <button onClick={toggleTheme}>{isDark ? '☀️ Light' : '🌙 Dark'}</button>;
}

Comparison Table

LibraryBest ForBundle SizeDevToolsLearning CurveTypeScript
useState/useReducerLocal component state0 (built-in)React DevToolsLowExcellent
Redux ToolkitLarge apps, complex client state, teams~40 KBRedux DevToolsHighExcellent
ZustandMid-size apps, shared client state~3 KBRedux DevTools compatLowExcellent
TanStack Query v5Server state, data fetching, caching~13 KBReact Query DevToolsMediumExcellent
JotaiAtomic state, fine-grained reactivity~3 KBJotai DevToolsLow-MediumExcellent
RTK QueryAPI state within Redux appsIncluded in RTKRedux DevToolsMediumExcellent

FAQ

Should I use Redux Toolkit or Zustand in 2026?

For most apps, Zustand is the better default. It has minimal boilerplate, no provider setup, and a simple API. Use Redux Toolkit when you need strict predictability, time-travel debugging, or when your team is already familiar with Redux and your app has genuinely complex state logic with many interrelated slices.

Can I use Zustand and TanStack Query together?

Yes — this is actually the recommended pattern. Zustand manages client-only state (cart, UI, preferences) and TanStack Query manages all server data. They don't conflict and work well side by side. Avoid copying server data into Zustand after fetching it.

What is staleTime in TanStack Query?

staleTime is the duration in milliseconds that fetched data is considered fresh. During this window, TanStack Query serves cached data without background refetching. After the window expires the data is "stale" and will be refetched when the query is next used (on mount, window focus, etc.). The default is 0, meaning every render triggers a background refetch.

How do optimistic updates work in TanStack Query?

In the onMutate callback, you cancel in-flight queries, snapshot the current cache value, then manually update the cache to reflect the expected result. If the mutation fails, onError restores the snapshot. On success or failure, onSettled invalidates the query to sync with the server's actual data.

Is the Redux Provider still required in 2026?

Yes. Redux still requires wrapping your app in <Provider store={store}>. Zustand and Jotai do not require any provider by default (though Jotai has an optional Provider for isolated state trees). This is one of the biggest DX differences between the libraries.