Our Pick Zustand — Minimal boilerplate, no provider wrapping, intuitive API, and excellent TypeScript support make Zustand the pragmatic default for most React apps. Redux Toolkit wins for large teams needing strict structure and enterprise DevTools.
Zustand vs Redux Toolkit

import ComparisonTable from ’../../components/ComparisonTable.astro’;

State management is one of React’s most discussed topics. Redux dominated for years; Zustand emerged as a simpler alternative. Both now have excellent TypeScript support and developer tools — the choice comes down to architecture philosophy.

Quick Verdict

Choose Zustand if: You’re building a new project and want minimal setup, your team values simplicity, or you find Redux boilerplate frustrating.

Choose Redux Toolkit if: You have a large team, need strong conventions enforced, or are maintaining an existing Redux codebase.


Feature Comparison

<ComparisonTable headers={[“Feature”, “Zustand”, “Redux Toolkit”]} rows={[ [“API surface”, “Minimal (~1KB)”, “Larger (actions, reducers, slices)”], [“Boilerplate”, “Very low”, “Low (RTK reduced it significantly)”], [“Provider required”, “No”, “Yes (Provider wrapper)”], [“DevTools”, “Redux DevTools compatible”, “Native Redux DevTools”], [“TypeScript”, “Excellent inference”, “Excellent (well-typed)”], [“Async handling”, “Direct in actions”, “createAsyncThunk”], [“Middleware”, “Middleware array”, “Middleware array”], [“Selectors”, “Direct state access”, “createSelector (memoized)”], [“Immer”, “Optional plugin”, “Built-in”], [“Bundle size”, “~1KB”, “~10KB”], ]} />


Store Definition

Zustand — minimal setup:

import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

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

interface CartState {
  items: CartItem[];
  isOpen: boolean;
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
  toggleCart: () => void;
  totalItems: () => number;
  totalPrice: () => number;
}

export const useCartStore = create<CartState>()(
  devtools(
    persist(
      (set, get) => ({
        items: [],
        isOpen: false,
        
        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 + 1 } : i
              ),
            };
          }
          return { items: [...state.items, { ...item, quantity: 1 }] };
        }),
        
        removeItem: (id) => set((state) => ({
          items: state.items.filter(i => i.id !== id),
        })),
        
        updateQuantity: (id, quantity) => set((state) => ({
          items: quantity === 0
            ? state.items.filter(i => i.id !== id)
            : state.items.map(i => i.id === id ? { ...i, quantity } : i),
        })),
        
        clearCart: () => set({ items: [] }),
        toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
        
        // Computed values as methods
        totalItems: () => get().items.reduce((sum, item) => sum + item.quantity, 0),
        totalPrice: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
      }),
      { name: 'cart-storage' }  // Persist to localStorage
    )
  )
);

// Usage in component — no Provider needed
function CartButton() {
  const { totalItems, toggleCart } = useCartStore();
  return (
    <button onClick={toggleCart}>
      Cart ({totalItems()})
    </button>
  );
}

Redux Toolkit — more structure:

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

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

interface CartState {
  items: CartItem[];
  isOpen: boolean;
  status: 'idle' | 'loading' | 'failed';
}

const initialState: CartState = {
  items: [],
  isOpen: false,
  status: 'idle',
};

// Async thunk for API calls
export const fetchCartFromServer = createAsyncThunk(
  'cart/fetchFromServer',
  async (userId: string) => {
    const response = await api.get(`/cart/${userId}`);
    return response.data as CartItem[];
  }
);

export const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addItem(state, action: PayloadAction<Omit<CartItem, 'quantity'>>) {
      // Immer enabled — direct mutation syntax
      const existing = state.items.find(i => i.id === action.payload.id);
      if (existing) {
        existing.quantity += 1;
      } else {
        state.items.push({ ...action.payload, quantity: 1 });
      }
    },
    removeItem(state, action: PayloadAction<string>) {
      state.items = state.items.filter(i => i.id !== action.payload);
    },
    updateQuantity(state, action: PayloadAction<{ id: string; quantity: number }>) {
      const item = state.items.find(i => i.id === action.payload.id);
      if (item) {
        item.quantity = action.payload.quantity;
      }
    },
    clearCart(state) {
      state.items = [];
    },
    toggleCart(state) {
      state.isOpen = !state.isOpen;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchCartFromServer.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchCartFromServer.fulfilled, (state, action) => {
        state.status = 'idle';
        state.items = action.payload;
      })
      .addCase(fetchCartFromServer.rejected, (state) => {
        state.status = 'failed';
      });
  },
});

export const { addItem, removeItem, updateQuantity, clearCart, toggleCart } = cartSlice.actions;

// Selectors
export const selectTotalItems = (state: RootState) => 
  state.cart.items.reduce((sum, item) => sum + item.quantity, 0);

export const selectTotalPrice = (state: RootState) =>
  state.cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);

// Store configuration
export const store = configureStore({
  reducer: {
    cart: cartSlice.reducer,
    // other slices...
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// App.tsx — Provider required
import { Provider } from 'react-redux';
import { store } from './store';

function App() {
  return (
    <Provider store={store}>
      <CartButton />
    </Provider>
  );
}

// CartButton.tsx
import { useSelector, useDispatch } from 'react-redux';
import { toggleCart } from './cartSlice';
import { selectTotalItems } from './cartSlice';

function CartButton() {
  const totalItems = useSelector(selectTotalItems);
  const dispatch = useDispatch();
  
  return (
    <button onClick={() => dispatch(toggleCart())}>
      Cart ({totalItems})
    </button>
  );
}

Async Data Fetching

Both libraries work well with server state libraries (TanStack Query, SWR), but handle local async differently:

Zustand async:

interface UserStore {
  user: User | null;
  loading: boolean;
  error: string | null;
  fetchUser: (id: string) => Promise<void>;
}

export const useUserStore = create<UserStore>()((set) => ({
  user: null,
  loading: false,
  error: null,
  
  fetchUser: async (id) => {
    set({ loading: true, error: null });
    try {
      const user = await api.getUser(id);
      set({ user, loading: false });
    } catch (error) {
      set({ loading: false, error: error.message });
    }
  },
}));

Redux createAsyncThunk:

// More structured but more code
export const fetchUser = createAsyncThunk(
  'user/fetch',
  async (id: string, { rejectWithValue }) => {
    try {
      return await api.getUser(id);
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

// Slice handles all three states automatically
// (pending, fulfilled, rejected) via extraReducers

Performance

Both perform well. The key difference is subscription granularity:

Zustand — subscribe to slices:

// Only re-render when items changes, not when isOpen changes
const items = useCartStore(state => state.items);

// Shallow equality check for object/array comparison
import { shallow } from 'zustand/shallow';
const { items, isOpen } = useCartStore(
  state => ({ items: state.items, isOpen: state.isOpen }),
  shallow
);

Redux — createSelector for memoization:

import { createSelector } from '@reduxjs/toolkit';

// Memoized selector — only recomputes when items change
const selectExpensiveItems = createSelector(
  (state: RootState) => state.cart.items,
  (items) => items.filter(item => item.price > 100)
);

// Component only re-renders when selector result changes
const expensiveItems = useSelector(selectExpensiveItems);

Developer Tools

Both integrate with Redux DevTools (browser extension):

// Zustand — add devtools middleware
import { devtools } from 'zustand/middleware';

const useStore = create(devtools((set) => ({
  // ...
}), { name: 'MyStore' }));

// Redux — built-in DevTools support
const store = configureStore({
  reducer: rootReducer,
  // DevTools enabled automatically in development
});

Redux DevTools has richer time-travel debugging. Both show state changes and actions.


When to Choose Each

Choose Zustand:

  • New projects (minimal setup)
  • Small to medium teams
  • When you want flexibility over convention
  • Prototypes and side projects
  • When Provider nesting bothers you

Choose Redux Toolkit:

  • Large teams needing enforced patterns
  • Complex state with many async flows
  • Enterprise apps with strict architecture requirements
  • Teams who prefer the strict action/reducer pattern
  • Migrating an existing Redux codebase

Alternatives Worth Knowing

Jotai — Atomic state management (Recoil-style, better):

const countAtom = atom(0);
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

TanStack Query — For server state (not UI state):

const { data, isPending } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => api.getUser(userId),
});

The modern recommendation: TanStack Query for server state, Zustand for client state. This combination handles 90% of state management needs with minimal boilerplate.


Bottom Line

Zustand has earned its place as the pragmatic default for new React projects. It’s simpler, requires less code, and is easier to learn than Redux Toolkit while still being capable. Redux Toolkit remains the better choice for large teams who benefit from its stricter conventions and more structured approach. Start with Zustand; switch to Redux Toolkit only if your team’s coordination needs demand it.