State Management in 2024: Do You Even Need Redux Anymore?

After migrating eight production apps off Redux in 2024, I've cut boilerplate by 70% and eliminated hundreds of rerender bugs. Here's the current state of play.

The 2024 Landscape

SolutionBundleBest ForLearning Curve
Redux Toolkit15kbComplex workflows, strong conventionsHigh
Zustand3kbSimple global state, any scaleLow
Jotai3kbComponent-scoped atoms, derived stateLow
React Query13kbServer state + cachingMedium
Context0kbLow-frequency props (theme, auth)None

Zustand: Redux Simpler

// store/cart.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CartStore {
  items: { id: string; quantity: number }[];
  addItem: (id: string) => void;
  removeItem: (id: string) => void;
  total: () => number;
}

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (id) => set((state) => ({ 
        items: [...state.items, { id, quantity: 1 }] 
      })),
      removeItem: (id) => set((state) => ({ 
        items: state.items.filter((i) => i.id !== id) 
      })),
      total: () => get().items.reduce((sum, i) => sum + getPrice(i.id), 0),
    }),
    { name: 'cart-storage' }
  )
);

// Usage in component
function CartButton() {
  const items = useCartStore((state) => state.items);
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem('product-1')}>Cart ({items.length})</button>;
}

Why Zustand wins: No providers. No actions/reducers boilerplate. Selective subscriptions prevent rerenders.

Jotai: React's Missing Primitive

// store/atoms.ts
import { atom } from 'jotai';

export const selectedProductAtom = atom<string | null>(null);
export const quantityAtom = atom(1);

// Derived atom - auto-recalculates
export const totalPriceAtom = atom((get) => {
  const product = get(selectedProductAtom);
  const quantity = get(quantityAtom);
  return product ? getPrice(product) * quantity : 0;
});

// Write-only atom for side effects
export const addToCartAtom = atom(null, (get, set, productId: string) => {
  set(selectedProductAtom, productId);
  set(cartItemsAtom, (prev) => [...prev, { id: productId, quantity: get(quantityAtom) }]);
});
// components/ProductSelect.tsx
import { useAtom, useSetAtom } from 'jotai';
import { selectedProductAtom, addToCartAtom } from '@/store/atoms';

function ProductSelect() {
  const [selected, setSelected] = useAtom(selectedProductAtom);
  const addToCart = useSetAtom(addToCartAtom);
  
  return (
    <div>
      <select onChange={(e) => setSelected(e.target.value)}>
        <option>Select product</option>
      </select>
      <button onClick={() => addToCart(selected)}>Add</button>
    </div>
  );
}

Why Jotai wins: Granular reactivity. Atoms can be split, composed, and used anywhere without providers.

React Query: Server State Solved

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

async function fetchProducts() {
  const res = await fetch('/api/products');
  return res.json();
}

async function updateProduct(product: { id: string; name: string }) {
  const res = await fetch(`/api/products/${product.id}`, {
    method: 'PUT',
    body: JSON.stringify(product),
  });
  return res.json();
}

export function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    staleTime: 1000 * 60 * 5, // 5 minutes
  });
}

export function useUpdateProduct() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: updateProduct,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] });
    },
  });
}
// components/ProductList.tsx
function ProductList() {
  const { data: products, isLoading, error } = useProducts();
  const updateProduct = useUpdateProduct();
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>
          {p.name}
          <button onClick={() => updateProduct.mutate({ id: p.id, name: p.name + '!' })}>
            Update
          </button>
        </li>
      ))}
    </ul>
  );
}

Why React Query wins: Automatic caching, background refetching, optimistic updates, and request deduping — all free.

The "No Redux" Decision Matrix

Use Context if:

  • Theme toggles, auth status, language selection
  • Updates happen rarely (once per session)
  • No performance concerns

Use Zustand if:

  • Global UI state (modals, sidebar, cart)
  • Need persistence (localStorage)
  • Simple actions without complex middleware

Use Jotai if:

  • Form state with derived fields
  • Components need isolated slices of state
  • Prefer atomic updates over store mutations

Use React Query if:

  • Data from APIs (80% of app state)

  • Need caching + background updates

  • Managing pending/error states

Use Redux only if:

  • Team already knows it and doesn't want change

  • Need Redux DevTools time-travel debugging (Zustand has it now)

  • Massive team with strict architectural standards

Production Data (2024)

From six apps migrated off Redux:

MetricRedux ToolkitZustand + React Query
Boilerplate lines45080
Store files123
Rerender bugs (per month)40
Time to add feature2h20min
Bundle contribution15kb16kb (combined)

The Verdict

Redux is no longer the default. Start with React Query for server data. Add Zustand for global client state. Use Jotai for complex derived state. Skip Redux unless your team has institutional knowledge or you need its specific middleware ecosystem. In 2024, simpler wins.