Skip to main content

Redux Toolkit vs Zustand in 2026: When to Use Which

·PkgPulse Team
0

TL;DR

Zustand for most apps; Redux Toolkit for large teams and complex state. Zustand (~10M weekly downloads) has much less boilerplate and is significantly easier to learn — a store is defined in five lines and consumed immediately. Redux Toolkit (~6M downloads) offers a full ecosystem: Redux DevTools with time-travel debugging, RTK Query for server state, normalized entity adapters, and enforced patterns that scale to large teams. For a solo developer or small team building a standard SaaS app, Zustand is almost always the better choice. For a large engineering team with complex interdependent state, Redux Toolkit's structure pays dividends.

Key Takeaways

  • Zustand: ~10M weekly downloads — Redux Toolkit: ~6M (npm, March 2026)
  • Zustand requires ~5 lines per store — Redux Toolkit requires slices, reducers, actions, and a root store
  • Redux DevTools is unmatched — time-travel debugging, state replay, action history
  • RTK Query gives Redux a built-in server state layer that competes with React Query
  • Zustand is lighter — ~1KB gzipped vs Redux Toolkit's ~12KB
  • Redux Toolkit download growth has plateaued — Zustand is still growing

Boilerplate Comparison: Same Counter Feature

The clearest way to see the difference is side-by-side implementations of the same feature:

// Zustand — minimal store definition
import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

// Usage — no Provider required
function Counter() {
  const { count, increment, decrement } = useCounterStore();
  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}
// Redux Toolkit — slice + store setup
import { createSlice, configureStore, type PayloadAction } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';

// 1. Define the slice
const counterSlice = createSlice({
  name: 'counter',
  initialState: { count: 0 },
  reducers: {
    increment: (state) => { state.count += 1; },
    decrement: (state) => { state.count -= 1; },
    reset: (state) => { state.count = 0; },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.count += action.payload;
    },
  },
});

export const { increment, decrement, reset } = counterSlice.actions;

// 2. Configure the store
const store = configureStore({
  reducer: { counter: counterSlice.reducer },
});

// 3. Type helpers
type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;

// 4. Wrap app in Provider
function App() {
  return <Provider store={store}><Counter /></Provider>;
}

// 5. Usage in components
function Counter() {
  const count = useSelector((state: RootState) => state.counter.count);
  const dispatch = useDispatch<AppDispatch>();
  return (
    <div>
      <button onClick={() => dispatch(decrement())}>-</button>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+</button>
    </div>
  );
}

Redux Toolkit is significantly less verbose than legacy Redux (no switch statements, no manual action creators), but still requires considerably more setup than Zustand for the same feature.


Async Operations

Real applications need to fetch data and handle loading/error states. Both libraries handle this, but with different philosophies:

// Zustand — async directly in store actions
import { create } from 'zustand';

interface PostsState {
  posts: Post[];
  loading: boolean;
  error: string | null;
  fetchPosts: () => Promise<void>;
  addPost: (post: Omit<Post, 'id'>) => Promise<void>;
}

const usePostsStore = create<PostsState>((set, get) => ({
  posts: [],
  loading: false,
  error: null,

  fetchPosts: async () => {
    set({ loading: true, error: null });
    try {
      const posts = await fetch('/api/posts').then(r => r.json());
      set({ posts, loading: false });
    } catch (e) {
      set({ loading: false, error: 'Failed to fetch posts' });
    }
  },

  addPost: async (post) => {
    const newPost = await fetch('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(post),
    }).then(r => r.json());
    set((state) => ({ posts: [...state.posts, newPost] }));
  },
}));
// Redux Toolkit — createAsyncThunk for async operations
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';

// Async thunk handles the three states automatically
export const fetchPosts = createAsyncThunk(
  'posts/fetchAll',
  async (_, { rejectWithValue }) => {
    try {
      const res = await fetch('/api/posts');
      if (!res.ok) throw new Error('Failed to fetch');
      return res.json();
    } catch (e) {
      return rejectWithValue((e as Error).message);
    }
  }
);

const postsSlice = createSlice({
  name: 'posts',
  initialState: {
    items: [] as Post[],
    loading: false,
    error: null as string | null,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchPosts.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.loading = false;
        state.items = action.payload;
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload as string;
      });
  },
});

Both approaches work. Zustand is simpler and more direct. Redux's createAsyncThunk is more verbose but enforces consistency: every async operation always has pending, fulfilled, and rejected states handled explicitly.


Redux DevTools: Time-Travel Debugging

Redux DevTools is Redux Toolkit's single biggest advantage, and nothing in the Zustand ecosystem matches it:

Redux DevTools features:
  Full action history with timestamps and type labels
  Jump to any previous state (time-travel)
  Replay action sequences from a bug report
  Import/export state snapshots — share exact app state with a colleague
  Diff view between consecutive states
  Filter actions by type or name
  Trace action to source code location
  Test in isolation: dispatch actions manually from DevTools

Zustand DevTools (via zustand/middleware devtools):
  Basic state inspection in Redux DevTools extension
  Action log (if actions are named in the middleware config)
  No time-travel
  No state snapshots
  No replay

For large apps where state bugs are hard to reproduce — e-commerce checkout flows, multi-step form wizards, real-time collaborative features — Redux DevTools alone can justify the extra boilerplate. Being able to jump to the exact state where a bug occurred, replay the action sequence, and export the state snapshot to share with a colleague is a significant debugging superpower.

// Zustand with named devtools middleware — better than nothing
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

const useCartStore = create<CartState>()(
  devtools(
    (set) => ({
      items: [],
      addItem: (item) => set(
        (state) => ({ items: [...state.items, item] }),
        false,          // don't replace state
        'cart/addItem', // action name shown in DevTools
      ),
    }),
    { name: 'CartStore' }
  )
);

Zustand Middleware Ecosystem

Zustand's middleware model is composable and covers most common needs:

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

const useSettingsStore = create<SettingsState>()(
  devtools(
    persist(
      immer(
        subscribeWithSelector((set) => ({
          theme: 'light' as 'light' | 'dark',
          language: 'en',
          setTheme: (theme) => set((state) => { state.theme = theme; }),
        }))
      ),
      {
        name: 'user-settings',    // localStorage key
        partialize: (state) => ({ theme: state.theme, language: state.language }),
      }
    ),
    { name: 'SettingsStore' }
  )
);

// React to specific changes without triggering full re-renders
useSettingsStore.subscribe(
  (state) => state.theme,
  (theme) => document.documentElement.setAttribute('data-theme', theme)
);

The available middleware covers: devtools (Redux DevTools integration), persist (localStorage/sessionStorage/custom storage), immer (Immer-based immutable updates), subscribeWithSelector (subscribe to slices of state), and combine (merging multiple store slices).


RTK Query: Redux's Data-Fetching Layer

If you need server state management (caching, polling, optimistic updates, cache invalidation), Redux Toolkit includes RTK Query — a full-featured data-fetching solution that competes directly with React Query:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const postsApi = createApi({
  reducerPath: 'postsApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Post'],
  endpoints: (builder) => ({
    getPosts: builder.query<Post[], void>({
      query: () => '/posts',
      providesTags: ['Post'],
    }),
    createPost: builder.mutation<Post, Omit<Post, 'id'>>({
      query: (body) => ({ url: '/posts', method: 'POST', body }),
      invalidatesTags: ['Post'], // Automatically refetches getPosts after create
    }),
  }),
});

export const { useGetPostsQuery, useCreatePostMutation } = postsApi;

// Usage in components — no manual loading/error state management
function PostList() {
  const { data: posts, isLoading, error } = useGetPostsQuery();
  const [createPost] = useCreatePostMutation();

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage />;
  return <ul>{posts?.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

This is a compelling reason to choose Redux Toolkit if you are not already using React Query or TanStack Query. RTK Query is tightly integrated with Redux DevTools, so every API call is visible in the action log.


Normalized State with Entity Adapters

For apps with large relational data collections (user lists, product catalogs, message threads), Redux Toolkit's entity adapter provides O(1) lookup without manual normalization:

import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';

const usersAdapter = createEntityAdapter<User>({
  selectId: (user) => user.id,
  sortComparer: (a, b) => a.name.localeCompare(b.name),
});

const usersSlice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState(),
  reducers: {
    addUser: usersAdapter.addOne,
    updateUser: usersAdapter.updateOne,
    removeUser: usersAdapter.removeOne,
    upsertUsers: usersAdapter.upsertMany,
  },
});

// Auto-generated selectors
const { selectAll, selectById, selectTotal } = usersAdapter.getSelectors(
  (state: RootState) => state.users
);

// O(1) lookup
const user = selectById(state, userId);

// Sorted list (respects sortComparer)
const allUsers = selectAll(state);

Zustand can achieve similar results with Maps or manual normalization, but there is no built-in equivalent to createEntityAdapter.


Package Health

PackageWeekly DownloadsSize (gzip)Latest VersionActive
@reduxjs/toolkit~6M~12KB2.xYes
zustand~10M~1KB5.xYes
react-redux~9M~4KB9.xYes
redux~13M~2KB5.xYes (via RTK)

Both packages are actively maintained. Zustand is developed by the same team behind Jotai, Valtio, and Jotai (Pmndrs collective). Redux Toolkit is maintained by the Redux team at Redux.js.org, with Mark Erikson as the primary maintainer.


When to Choose

Choose Zustand when:

  • Solo developer or small team (1-5 people)
  • Standard SaaS app state (auth, UI state, cart, preferences)
  • You want to ship quickly without boilerplate overhead
  • Your team does not need time-travel debugging in day-to-day work
  • Migrating away from Redux — Zustand is the lowest-friction migration target
  • Performance is critical — Zustand's minimal footprint reduces bundle size

Choose Redux Toolkit when:

  • Large team (5+) needs enforced conventions and shared patterns
  • State shape is complex with many interdependent slices requiring predictable updates
  • Time-travel debugging and action replay are genuinely needed for bug investigation
  • You have existing Redux code — RTK is the modern upgrade path (not a full rewrite)
  • App has large normalized collections requiring entity adapters
  • You want RTK Query instead of adding a separate server state library
  • Team includes junior developers who benefit from the enforced one-way data flow pattern

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.