Zustand vs Redux: What Actually Matters in Real React Projects

Jun 25, 2026 min read

Last month I was reviewing code in one of my last project and then I spend three hours debugging a Redux action that should have taken ten minutes to write. The action was five files deep: action creator, thunk middleware, reducer split across two handlers, selectors, and tests. Meanwhile, their actual problem was a single boolean flag that needed to toggle.

That’s when the question always comes up: why not just use Zustand?

I’ve been on both sides of this argument. I’ve shipped Redux apps that scaled beautifully across dozens of developers. I’ve also rebuilt unnecessarily complex Redux stores into clean Zustand implementations. The answer isn’t “one is better” , it’s “they solve different problems at different scales.”

So let’s be practical about it.

The Setup: What You’re Actually Choosing

Redux is a philosophy wrapped in a library. It enforces a specific mental model: single store, immutable updates, actions and reducers, time-travel debugging. You’re buying into a strict pattern because that pattern forces discipline at scale.

Zustand is a minimalist library with one job: make state management simple. It gives you a hook, some utilities, and gets out of your way. You manage the philosophy yourself.

This difference sounds small. It’s not.

Size and Setup: The Time Tax

Redux with its ecosystem typically weighs around 40KB (including DevTools and middleware). Your setup file looks like this:

// Redux setup
import { configureStore } from '@reduxjs/toolkit';
import { userReducer } from './slices/user';
import { uiReducer } from './slices/ui';

const store = configureStore({
  reducer: {
    user: userReducer,
    ui: uiReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(yourCustomMiddleware),
});

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

Zustand setup:

// Zustand setup
import { create } from 'zustand';

export const useUserStore = create((set) => ({
    user: null,
    setUser: (user) => set({ user }),
    clearUser: () => set({ user: null }),
}));

export const useUIStore = create((set) => ({
    isModalOpen: false,
    toggleModal: () => set((state) => ({ isModalOpen: !state.isModalOpen })),
}));

Zustand is 2KB. The setup takes minutes, not hours. For small to medium projects , most projects , Redux setup feels like ceremony you didn’t ask for.

Data Flow: Explicit vs Implicit

Redux forces you to think in messages. You dispatch an action. That action flows through middleware. Reducers handle it. The component re-renders. You can trace the entire path.

// Redux: explicit flow
dispatch(userSlice.actions.setUser(userData));
// → middleware sees it
// → reducer processes it
// → selector derives new state
// → component re-renders

Zustand is direct. You call the function. State updates. Component re-renders. No middleware, no middleware pipeline, no extra concepts to name.

// Zustand: direct flow
useUserStore.getState().setUser(userData);
// → state updates immediately
// → component re-renders

For most applications, this is a feature, not a liability. You don’t need to debug a message pipeline to figure out why a user’s avatar didn’t update. You just read the code.

The catch: Redux’s explicitness becomes an asset when state mutations matter. If you have complex business logic optimistic updates, undo/redo, time-travel debugging, transaction rollback Redux’s architecture makes those patterns natural. Zustand doesn’t prevent them, but they require more deliberate engineering on your part.

DevTools and Debugging

Redux DevTools is exceptional. You can replay actions, jump to any state snapshot, dispatch actions manually, and see exactly which action caused which state change. It’s built into the philosophy.

Zustand has middleware for similar debugging, and the community has built tools around it. But it’s not the default. You have to opt in.

If your app is business-critical and state is complex, Redux DevTools alone is worth real consideration. For most projects, logging and React DevTools are sufficient.

TypeScript Integration

Both work well with TypeScript, but they feel different.

Redux requires explicit typing at each layer:

// Redux with TypeScript
type AuthState = {
    user: User | null;
    isLoading: boolean;
};

const authSlice = createSlice({
    name: 'auth',
    initialState: { user: null, isLoading: false } as AuthState,
    reducers: {
        setUser: (state, action: PayloadAction<User>) => {
            state.user = action.payload;
        },
    },
});

type RootState = ReturnType<typeof store.getState>;
export const selectUser = (state: RootState) => state.auth.user;

Zustand infers types more naturally:

// Zustand with TypeScript
interface AuthStore {
    user: User | null;
    isLoading: boolean;
    setUser: (user: User) => void;
}

export const useAuthStore = create<AuthStore>((set) => ({
    user: null,
    isLoading: false,
    setUser: (user) => set({ user }),
}));

Zustand’s approach reads more like regular code. Redux requires more scaffolding, but that scaffolding makes refactoring across large teams more predictable.

Middleware and Async Operations

Redux was built for complexity. Middleware is the first-class abstraction:

// Redux: middleware approach
const store = configureStore({
    reducer,
    middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware().concat(thunkMiddleware).concat(loggerMiddleware),
});

// Async operations feel intentional
dispatch(fetchUser(userId)); // thunk handles the whole lifecycle

Zustand doesn’t have native middleware. Instead, you write functions that call the store:

// Zustand: function approach
export const fetchUser = async (userId: string) => {
    useAuthStore.setState({ isLoading: true });
    try {
        const user = await api.getUser(userId);
        useAuthStore.setState({ user, isLoading: false });
    } catch (error) {
        useAuthStore.setState({ error, isLoading: false });
    }
};

It’s less structured, but for most async operations, it’s clearer. You’re not wrapping things in thunks or learning thunk conventions. You’re just writing functions.

However, for complex async orchestration , retries with exponential backoff, request deduplication, cancellation tokens, state synchronization across multiple async flows , Redux’s middleware pattern provides better abstractions.

When to Choose Redux

  • Large teams. Redux’s constraints make code reviewable and predictable at scale.
  • Complex state logic. Business rules, derived state, conditional updates. Redux’s reducers make this explicit.
  • DevTools matter. Time-travel debugging, replay, and state inspection are built in.
  • Undo/redo or transactions. Redux’s immutable update pattern makes these patterns natural.
  • Existing Redux codebase. Switching is an organizational cost that rarely pays for itself.

When to Choose Zustand

  • Small to medium projects. Features and shipping speed matter more than architectural purity.
  • Simple state shapes. You don’t have deeply nested, interdependent state. You have independent concerns.
  • Distributed state. Multiple stores for different features. Each feature owns its state completely.
  • Rapid prototyping. You need to move fast and debugging can wait.
  • Type inference feels better to you. You like writing state updates that look like normal code.

The Honest Middle Ground

Neither is a moral failure. The right choice depends on your project’s present state, team size, and how much state complexity you’re actually managing.

I’ve shipped both successfully. I’ve also shipped a Redux app that was overkill for its actual complexity, and a Zustand app that became unmaintainable because state got too distributed.

The mistake isn’t picking wrong. It’s picking based on what sounded good in a blog post instead of what your project actually needs right now.

Zustand won’t save a poorly architected app. Redux won’t make a simple app better. Both are tools. Use them when they fit.


If you’ve shipped both and landed somewhere different, I’d genuinely like to hear it. These choices are almost always contextual.

— Parsa