Zustand is surprisingly not a Redux competitor, but rather a Redux enhancer that you can use instead of Redux.

Let’s see what this looks like in practice. Imagine you’ve got a simple counter.

With Zustand, you’d define your "store" like this:

import { create } from 'zustand';

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

export default useCounterStore;

And then use it in your React component:

import React from 'react';
import useCounterStore from './counterStore';

function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
}

export default Counter;

Notice how concise that is? No Provider, no useSelector, no useDispatch. Just create and then a hook that selects exactly what you need. This is because Zustand is built around a hook-first API, making it feel much more integrated with React.

Redux Toolkit, on the other hand, is a set of utilities that aim to simplify Redux. It doesn’t fundamentally change how Redux works but makes it easier to set up and use.

Here’s the equivalent with Redux Toolkit:

First, the store setup:

import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice'; // We'll define this next

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

Next, the slice (which combines actions and reducers):

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

const initialState = { value: 0 };

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action) => {
      state.value += action.payload;
    },
  },
});

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

export default counterSlice.reducer;

And then in your React component, you’d wrap your app in a Provider and use useSelector and useDispatch:

import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from './counterSlice'; // Import actions

function Counter() {
  const count = useSelector((state) => state.counter.value);
  const dispatch = useDispatch();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch(increment())}>Increment</button>
      <button onClick={() => dispatch(decrement())}>Decrement</button>
    </div>
  );
}

export default Counter;

You also need to wrap your root component with <Provider store={store}>.

The core problem Zustand solves is the boilerplate and perceived complexity of Redux. For many applications, especially smaller to medium-sized ones, the setup for Redux can feel like overkill. Zustand offers a much more direct path from state definition to component usage. It achieves this by leveraging custom hooks and a simpler API for defining state slices and updates. Instead of separate actions and reducers bundled by configureStore, Zustand uses a single create function where you define your state and the functions that modify it directly. This reduces the number of files and concepts you need to manage.

The mental model for Zustand is that you’re creating a "hook" that holds your state. Any component can import and use this hook. When you call set inside a Zustand store, it automatically triggers re-renders only in components that are subscribed to the state that changed. This is similar to how useSelector works in Redux, but it’s built into the core API of Zustand.

Redux Toolkit’s primary goal is to make Redux more approachable and less error-prone. It does this by providing sensible defaults and abstracting away common Redux patterns. configureStore automatically sets up the Redux DevTools extension and combines reducers, while createSlice generates action creators and reducers for you, preventing common mistakes like forgetting to update state immutably. It’s still Redux, just a much more streamlined version.

The most surprising thing about Zustand is how it manages selectors. When you select a piece of state like useCounterStore((state) => state.count), Zustand performs a shallow comparison. If the selected value hasn’t changed, the component won’t re-render. This is efficient, but it means you need to be mindful of how you structure your selectors. If you select an object and mutate it, the shallow comparison won’t detect the change, leading to missed updates. This is why Zustand’s set function often takes a function like (state) => ({ count: state.count + 1 }) rather than directly returning a new state object; it ensures that Immer (which Zustand uses under the hood for immutability) can track changes correctly.

Ultimately, the choice in 2024 often boils down to team familiarity and project scale. For new projects where a global state manager is needed and the team is comfortable with hooks, Zustand offers a faster development experience with less overhead. If your team already has deep Redux expertise or the project is a large, complex application with many interconnected pieces of state, Redux Toolkit remains a robust and well-supported choice.

The next step is exploring how each library handles asynchronous operations.

Want structured learning?

Take the full React course →