Back to Scaling Node.js Applications guides

Zustand vs. Redux Toolkit vs. Jotai

Stanley Ulili
Updated on May 19, 2025

Looking for the right state management solution for your React app in 2025? You've got three top contenders to choose from.

Redux Toolkit is the established heavyweight - it's structured, predictable, and packed with features for complex apps. Many large teams still rely on it when they need the full package.

Zustand has become the popular middle ground - it's simple to use but surprisingly powerful. If you hate boilerplate code but still need solid state management, this might be your best bet.

Jotai takes a different approach with its atom-based system. It's like having tiny, independent pieces of state that work together. Great for apps where you need precise control over what rerenders.

Let's break down how these three options actually work in real projects so you can pick the right one for your needs.

What is Redux Toolkit?

Screenshot of Redux Toolkit website

Redux Toolkit (RTK) is the official, streamlined version of Redux that cuts down on all the code you used to have to write. The Redux team created it in 2019 to address the biggest complaints people had: too much boilerplate, complex setup, and repetitive patterns.

RTK keeps all the core principles that made Redux popular - the predictable state container, unidirectional data flow, and immutable updates - but packages them in a much more developer-friendly way. It gives you utilities to create "slices" of state, generate actions, and set up your store with sensible defaults built in.

While simpler than traditional Redux, RTK is still on the heavier side compared to newer alternatives. But that weight comes with benefits - powerful middleware support, extensive developer tools, and a huge ecosystem of extensions. For large apps with complex state needs, these features can be worth the extra complexity.

What is Zustand?

Screenshot of Zustand GitHub page

Zustand (German for "state") is a refreshingly simple approach to state management. Created by the team behind Three.js and React Spring, it strips away complexity while keeping the power.

The main appeal of Zustand? It just feels natural to use with React. Unlike Redux's separate "actions" and "reducers," Zustand lets you define your state and the functions that update it all in one place. No need for action types, switch statements, or complex setup.

Zustand's hook-based API means you can grab exactly the piece of state you need in any component without wrapping your entire app in providers. This makes it extremely easy to get started with and incrementally adopt in existing projects.

With a tiny bundle size (about 3KB) and practically no boilerplate, Zustand has become the go-to choice for developers who want state management that just gets out of their way and lets them build features.

What is Jotai?

Screenshot of Jotai website

Jotai takes a completely different approach to state management with its "atomic" model. Instead of putting all your state in one big store, Jotai lets you create tiny, independent pieces of state called atoms.

Think of atoms like LEGO blocks – small, simple pieces that you can connect together to build something complex. Each atom contains a small piece of your application state, and components only rerender when the specific atoms they use change.

This approach means Jotai excels at preventing unnecessary rerenders. When a piece of state updates, only the components using that exact piece will refresh – not the entire component tree or anything using the store.

Jotai was built from the ground up with TypeScript and works seamlessly with React's Suspense feature for handling loading states. Its small bundle size (around 4KB) and modern approach make it especially great for performance-critical applications.

Zustand vs. Redux Toolkit vs. Jotai: a quick comparison

Your choice of state management has a big impact on both development speed and app performance. Each library has its own philosophy that makes it better for different situations.

Here's a quick breakdown of the key differences:

Feature Zustand Redux Toolkit Jotai
Core concept Single store with hook-based access Centralized store with slices and selectors Atomic state model with composable atoms
Bundle size ~3KB ~14KB (including Redux core) ~4KB
Learning curve Gentle, minimal concepts Moderate, structured patterns to learn Gentle, familiar React-like principles
Boilerplate Minimal Reduced compared to vanilla Redux Minimal
DevTools support Built-in Redux DevTools integration Comprehensive DevTools ecosystem Basic DevTools support
Middleware/plugins Simple middleware API Extensive middleware ecosystem Limited but growing middleware support
TypeScript support First-class TypeScript integration Excellent type safety Built with TypeScript from the ground up
Performance Highly optimized with selective re-renders Good with proper memoization Excellent with atomic updates
Async handling Simple thunks pattern createAsyncThunk with status handling Derived atoms and suspense integration
Component integration Direct hooks without providers Requires Provider wrapping Requires Provider wrapping
Persistence Built-in persist middleware Requires redux-persist Built-in persistence utilities
Server state integration Requires custom implementation RTK Query for data fetching Integrates well with React Query
Community size Growing rapidly Large, established ecosystem Smaller but active community
Documentation Concise but comprehensive Extensive with many examples Growing, well-structured

Store creation and setup

The way you set up state reveals each library's core philosophy and directly impacts your development experience. Let's look at their different approaches.

Redux Toolkit follows a structured, configuration-based approach:

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

const usersSlice = createSlice({
  name: 'users',
  initialState: { list: [], loading: false, error: null },
  reducers: {
    addUser: (state, action) => {
      state.list.push(action.payload);
    },
    // other reducers...
  }
});

const store = configureStore({
  reducer: {
    users: usersSlice.reducer,
  }
});

export const { addUser } = usersSlice.actions;

Redux Toolkit organizes related state and actions together in "slices" - a concept that helps you keep things structured as your app grows. The downside? More code to write upfront.

Zustand takes a much more streamlined approach:

 
import create from 'zustand';

const useUserStore = create((set) => ({
  users: [],
  loading: false,
  error: null,

  addUser: (user) => set((state) => ({ 
    users: [...state.users, user] 
  })),

  // other actions...
}));

Zustand's approach feels more natural in React - you define your state and the functions that update it all in one place. No more jumping between action creators and reducers.

Jotai embraces an atom-based approach:

 
import { atom } from 'jotai';

const usersAtom = atom([]);
const loadingAtom = atom(false);

const addUserAtom = atom(
  null,
  (get, set, newUser) => {
    set(usersAtom, [...get(usersAtom), newUser]);
  }
);

// Export atoms
export { usersAtom, loadingAtom, addUserAtom };

Jotai breaks down state into tiny, independent atoms that you can compose together. This takes some getting used to if you've worked with traditional stores, but it can lead to cleaner, more precise state updates.

Component integration

How easily a state library connects with your React components makes a huge difference in your day-to-day coding.

Redux Toolkit follows Redux's provider pattern, which means wrapping your app and using special hooks:

 
import { Provider, useSelector, useDispatch } from 'react-redux';
import store from './store';
import { addUser, fetchUsers } from './usersSlice';

// You need to wrap your app with Provider
const App = () => (
  <Provider store={store}>
    <UserList />
    <AddUserForm />
  </Provider>
);

// Component to display users
const UserList = () => {
  const dispatch = useDispatch();
  const { users, loading, error } = useSelector(state => state.users);

  useEffect(() => {
    dispatch(fetchUsers());
  }, [dispatch]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

// Component to add a user
const AddUserForm = () => {
  const dispatch = useDispatch();
  const [name, setName] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (name) {
      dispatch(addUser({ id: Date.now(), name }));
      setName('');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button type="submit">Add User</button>
    </form>
  );
};

Notice how Redux requires you to dispatch actions instead of calling functions directly. This indirection adds structure but also means more code. You also need to use selectors carefully to avoid unnecessary rerenders.

Zustand makes things much simpler with direct hook usage:

 
import useUserStore from './userStore';
import { useEffect, useState } from 'react';

// No provider needed!
const App = () => (
  <>
    <UserList />
    <AddUserForm />
  </>
);

// Component to display users
const UserList = () => {
  // Just grab what you need
  const users = useUserStore(state => state.users);
  const loading = useUserStore(state => state.loading);
  const error = useUserStore(state => state.error);
  const fetchUsers = useUserStore(state => state.fetchUsers);

  useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

// Component to add a user
const AddUserForm = () => {
  // Get just the function you need
  const addUser = useUserStore(state => state.addUser);
  const [name, setName] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (name) {
      addUser({ id: Date.now(), name });
      setName('');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button type="submit">Add User</button>
    </form>
  );
};

See how Zustand lets you directly call functions without dispatching actions? It also doesn't need a Provider wrapping your app. This simplicity is why many developers love it. You can also cherry-pick exactly the pieces of state you need in each component.

Jotai takes the atomic approach with specialized hooks:

 
import { Provider, useAtom, useAtomValue, useSetAtom } from 'jotai';
import { 
  usersAtom, 
  loadingAtom, 
  errorAtom, 
  addUserAtom, 
  fetchUsersAtom 
} from './atoms';
import { useEffect, useState } from 'react';

// Needs a Provider, but it's simpler than Redux
const App = () => (
  <Provider>
    <UserList />
    <AddUserForm />
  </Provider>
);

// Component to display users
const UserList = () => {
  // Read-only atoms with specialized hooks
  const users = useAtomValue(usersAtom);
  const loading = useAtomValue(loadingAtom);
  const error = useAtomValue(errorAtom);

  // Action atom with useSetAtom
  const fetchUsers = useSetAtom(fetchUsersAtom);

  useEffect(() => {
    fetchUsers();
  }, [fetchUsers]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

// Component to add a user
const AddUserForm = () => {
  // Action atom
  const addUser = useSetAtom(addUserAtom);
  const [name, setName] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (name) {
      addUser({ id: Date.now(), name });
      setName('');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={e => setName(e.target.value)} />
      <button type="submit">Add User</button>
    </form>
  );
};

Jotai's approach is more granular - notice how it has different hooks for reading (useAtomValue) and writing (useSetAtom). This precision helps React optimize rendering. Components only update when the exact atoms they subscribe to change.

Handling asynchronous operations

Managing API calls and other async operations is crucial for real-world apps. Each library tackles this differently.

Redux Toolkit provides a specialized tool called createAsyncThunk:

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

export const fetchUsers = createAsyncThunk(
  'users/fetchUsers',
  async (_, { rejectWithValue }) => {
    try {
      const response = await fetch('/api/users');
      if (!response.ok) throw new Error('Server error!');
      return await response.json();
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

const usersSlice = createSlice({
  name: 'users',
  initialState: { list: [], status: 'idle', error: null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.list = action.payload;
      })
      // Similar for rejected case
  }
});

Redux Toolkit's async approach is very structured. Your thunk automatically creates three action types (pending, fulfilled, rejected), and you handle each state in the extraReducers section.

Zustand takes a much more direct approach:

 
import create from 'zustand';

const useUserStore = create((set, get) => ({
  users: [],
  loading: false,
  error: null,

  // Just put async functions right in the store
  fetchUsers: async () => {
    set({ loading: true, error: null });
    try {
      const response = await fetch('/api/users');
      const users = await response.json();
      set({ users, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  }
}));

With Zustand, you just put async functions directly in your store. No special wrappers needed - just regular async functions that call set when they're done.

Jotai uses derived atoms and integrates with React's Suspense feature:

 
import { atom } from 'jotai';

// Basic atoms
const usersAtom = atom([]);
const loadingAtom = atom(false);
const errorAtom = atom(null);

// Async fetch atom
const fetchUsersAtom = atom(
  null,
  async (get, set) => {
    set(loadingAtom, true);
    try {
      const response = await fetch('/api/users');
      const users = await response.json();
      set(usersAtom, users);
      set(loadingAtom, false);
    } catch (error) {
      set(errorAtom, error.message);
      set(loadingAtom, false);
    }
  }
);

Jotai's approach fits nicely with React's Suspense feature. By throwing promises during loading states, it can automatically trigger Suspense boundaries.

Developer tools and debugging

Good debugging tools can save you hours of headaches. Each library offers different ways to peek inside your app's state.

Redux Toolkit provides comprehensive developer tools:

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

const store = configureStore({
  reducer: rootReducer,
  // DevTools enabled by default in development
  devTools: process.env.NODE_ENV !== 'production'
});

With Redux DevTools, you can time-travel through state changes, replay actions, and inspect your entire state tree - powerful features for complex apps.

Zustand also connects to Redux DevTools with minimal setup:

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

const useStore = create(
  devtools((set) => ({
    bears: 0,
    increaseBears: () => set(state => ({ bears: state.bears + 1 }))
  }))
);

This gives you state inspection, time-travel debugging, and action tracking without Redux's complexity.

Jotai's developer tools focus on working with React DevTools:

 
import { useAtomsDevtools } from 'jotai/devtools';

const DebuggedApp = () => {
  // Connect to React DevTools
  useAtomsDevtools('MyApp');

  return <App />;
};

This helps you see atom values in the React component tree and track dependencies between atoms.

Ecosystem and extensions

strong ecosystem helps you avoid reinventing the wheel by offering ready-made solutions. Each library has its own set of tools and plugins.

Redux Toolkit benefits from Redux's mature ecosystem:

 
import { configureStore } from '@reduxjs/toolkit';
import { persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import { api } from './services/api';

// Set up persistence
const persistConfig = { key: 'root', storage, whitelist: ['auth'] };
const persistedReducer = persistReducer(persistConfig, rootReducer);

const store = configureStore({
  reducer: {
    app: persistedReducer,
    [api.reducerPath]: api.reducer
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(api.middleware)
});

Redux Toolkit's ecosystem includes RTK Query for data fetching, Redux Persist for storage, and many other extensions for nearly any state management need.

Zustand offers a simpler set of extensions through middleware:

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

const useStore = create(
  persist(
    immer((set) => ({
      users: [],
      addUser: (user) => set((state) => {
        // Immer lets you write "mutable" code
        state.users.push(user);
      })
    })),
    { name: 'user-storage' }
  )
);

Zustand includes middleware for persistence, immer integration, and subscriptions - covering the most common needs with less complexity.

Jotai focuses on atom utilities:

 
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { atomWithQuery } from 'jotai-tanstack-query';

// Local storage integration
const themeAtom = atomWithStorage('theme', 'light');

// React Query integration
const userAtom = atomWithQuery(get => ({
  queryKey: ['user', get(userIdAtom)],
  queryFn: async ({ queryKey }) => {
    const [_, userId] = queryKey;
    return fetch(`/api/users/${userId}`).then(res => res.json());
  }
}));

Jotai's extensions are focused on atom-based patterns, with tools for storage, React Query integration, and more.

Performance considerations

Performance is a critical factor in state management, affecting both user experience and development productivity.

Redux Toolkit has made significant improvements over vanilla Redux, but still requires careful optimization:

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

// Define base selectors
const selectUsers = state => state.users.list;
const selectFilter = state => state.users.filter;

// Create memoized selectors for derived data
const selectFilteredUsers = createSelector(
  [selectUsers, selectFilter],
  (users, filter) => {
    console.log('Computing filtered users');
    return filter
      ? users.filter(user => user.name.includes(filter))
      : users;
  }
);

// Component using the selector
const UserList = () => {
  // Only re-renders when the filtered result changes
  const filteredUsers = useSelector(selectFilteredUsers);

  return (
    <ul>
      {filteredUsers.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

Redux Toolkit's performance considerations include: - Using createSelector for memoization to prevent unnecessary recalculations - Careful structuring of the state tree to minimize updates - Using shallowEqual as a second argument to useSelector when needed - Leveraging the normalized state pattern for relational data

With proper optimization, Redux Toolkit can achieve good performance even in large applications, but it requires more explicit optimization.

Zustand provides built-in performance optimizations with its selector pattern:

 
import { create } from 'zustand';

const useStore = create((set) => ({
  users: [],
  filter: '',
  setFilter: (filter) => set({ filter }),
  addUser: (user) => set(state => ({
    users: [...state.users, user]
  }))
}));

// Component with automatic optimization
const UserList = () => {
  // Only re-renders when users or filter changes
  const filter = useStore(state => state.filter);
  const users = useStore(state => state.users);

  // Computation happens during render, but component only
  // re-renders when dependencies change
  const filteredUsers = useMemo(() => {
    return filter
      ? users.filter(user => user.name.includes(filter))
      : users;
  }, [users, filter]);

  return (
    <ul>
      {filteredUsers.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

// Even more optimized approach
const UserListOptimized = () => {
  // Component only re-renders when filtered results change
  const filteredUsers = useStore(state => {
    return state.filter
      ? state.users.filter(user => user.name.includes(state.filter))
      : state.users;
  });

  return (
    <ul>
      {filteredUsers.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

Zustand's performance features include: - Automatic selector comparison to prevent unnecessary re-renders - Minimal re-renders out of the box - Built-in shallow comparison - Ability to use fine-grained selectors for optimal component updates

Zustand achieves strong performance with minimal explicit optimization, making it developer-friendly while maintaining efficiency.

Jotai excels at fine-grained updates with its atomic model:

 
import { atom, useAtom, useAtomValue } from 'jotai';

// Define base atoms
const usersAtom = atom([]);
const filterAtom = atom('');

// Create a derived atom for filtered users
const filteredUsersAtom = atom(get => {
  const users = get(usersAtom);
  const filter = get(filterAtom);

  return filter
    ? users.filter(user => user.name.includes(filter))
    : users;
});

// Components with minimal renders
const FilterInput = () => {
  const [filter, setFilter] = useAtom(filterAtom);

  return (
    <input
      value={filter}
      onChange={e => setFilter(e.target.value)}
      placeholder="Filter users..."
    />
  );
};

const UserList = () => {
  // Only re-renders when filteredUsersAtom changes
  const filteredUsers = useAtomValue(filteredUsersAtom);

  return (
    <ul>
      {filteredUsers.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

const AddUserButton = () => {
  const [users, setUsers] = useAtom(usersAtom);

  const addUser = () => {
    const newUser = { id: Date.now(), name: `User ${users.length + 1}` };
    setUsers([...users, newUser]);
  };

  // Only re-renders when users atom changes
  return <button onClick={addUser}>Add User</button>;
};

Jotai's performance advantages include: - Fine-grained reactivity with atom-level updates - Minimal re-renders by default - Component-specific subscriptions to relevant atoms only - Efficient derived state through atom composition

Jotai's atomic approach provides excellent performance characteristics with minimal developer intervention, especially for applications with frequently changing, independent pieces of state.

Final thoughts

In this article, you explored Redux Toolkit, Zustand, and Jotai, three top state management libraries for React in 2025.

Redux Toolkit is structured and full-featured, ideal for complex applications. Zustand is simple and flexible, with minimal setup. Jotai uses an atomic model that offers fine-grained control and strong performance.

Each has its strengths. Pick the one that best matches your app’s needs, your team's workflow, and your preferred development style.

Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github