Back to Blog
9 min readGuide

React State Management in 2024: A Comprehensive Comparison

Compare the top state management solutions for React in 2024 - from Context API to Zustand, Redux Toolkit, and Jotai

React State Management in 2024: A Comprehensive Comparison

State management remains one of the most crucial decisions in React development. With so many options available in 2024, choosing the right solution can be overwhelming. Let's compare the top approaches and help you make an informed decision.

The State Management Landscape

Option 1: Built-in React (useState + Context)

Best for: Small to medium apps, simple global state

Pros:

  • Zero additional dependencies
  • Simple API, easy to learn
  • Built into React

Cons:

  • Can cause unnecessary re-renders
  • Verbose for complex state
  • No built-in dev tools
import { createContext, useContext, useState } from 'react';

const UserContext = createContext();

export function UserProvider({ children }) {
  const [user, setUser] = useState(null);

  const login = async (credentials) => {
    const userData = await authAPI.login(credentials);
    setUser(userData);
  };

  const logout = () => setUser(null);

  return (
    <UserContext.Provider value={{ user, login, logout }}>
      {children}
    </UserContext.Provider>
  );
}

export function useUser() {
  const context = useContext(UserContext);
  if (!context) throw new Error('useUser must be used within UserProvider');
  return context;
}

// Usage
function App() {
  return (
    <UserProvider>
      <Dashboard />
    </UserProvider>
  );
}

function Dashboard() {
  const { user, logout } = useUser();
  return <div>Welcome {user?.name}</div>;
}

Option 2: Redux Toolkit

Best for: Large apps, complex state logic, time-travel debugging

Pros:

  • Excellent DevTools
  • Predictable state updates
  • Large ecosystem
  • Built-in async handling (RTK Query)

Cons:

  • More boilerplate than alternatives
  • Steeper learning curve
  • Can be overkill for simple apps
// store/userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const loginUser = createAsyncThunk(
  'user/login',
  async (credentials) => {
    const response = await authAPI.login(credentials);
    return response.data;
  }
);

const userSlice = createSlice({
  name: 'user',
  initialState: {
    data: null,
    loading: false,
    error: null,
  },
  reducers: {
    logout: (state) => {
      state.data = null;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(loginUser.pending, (state) => {
        state.loading = true;
      })
      .addCase(loginUser.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      .addCase(loginUser.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      });
  },
});

export const { logout } = userSlice.actions;
export default userSlice.reducer;

// store/index.js
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';

export const store = configureStore({
  reducer: {
    user: userReducer,
  },
});

// Usage
import { Provider, useSelector, useDispatch } from 'react-redux';

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

function Dashboard() {
  const { data: user, loading } = useSelector((state) => state.user);
  const dispatch = useDispatch();

  const handleLogin = (credentials) => {
    dispatch(loginUser(credentials));
  };

  return <div>Welcome {user?.name}</div>;
}

Option 3: Zustand

Best for: Medium to large apps, simpler alternative to Redux

Pros:

  • Minimal boilerplate
  • No Provider wrapping needed
  • Simple API, easy to learn
  • Small bundle size (~1KB)
  • Built-in DevTools support

Cons:

  • Smaller ecosystem than Redux
  • Less structured (can lead to inconsistency)
import { create } from 'zustand';

const useUserStore = create((set) => ({
  user: null,
  loading: false,
  error: null,

  login: async (credentials) => {
    set({ loading: true });
    try {
      const userData = await authAPI.login(credentials);
      set({ user: userData, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },

  logout: () => set({ user: null }),
}));

// Usage - No Provider needed!
function Dashboard() {
  const { user, login, logout } = useUserStore();

  return (
    <div>
      <h1>Welcome {user?.name}</h1>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

// Selective subscription (only re-renders when user changes)
function UserName() {
  const userName = useUserStore((state) => state.user?.name);
  return <span>{userName}</span>;
}

Option 4: Jotai

Best for: Bottom-up atomic state management

Pros:

  • Atomic state approach
  • Minimal boilerplate
  • TypeScript friendly
  • Supports React Suspense
  • No Provider needed for basic use

Cons:

  • Different mental model
  • Smaller community
  • Less tooling
import { atom, useAtom } from 'jotai';

// Define atoms
const userAtom = atom(null);
const loadingAtom = atom(false);

// Derived atom
const userNameAtom = atom((get) => get(userAtom)?.name ?? 'Guest');

// Async atom
const loginAtom = atom(
  null,
  async (get, set, credentials) => {
    set(loadingAtom, true);
    try {
      const userData = await authAPI.login(credentials);
      set(userAtom, userData);
    } finally {
      set(loadingAtom, false);
    }
  }
);

// Usage
function Dashboard() {
  const [user] = useAtom(userAtom);
  const [, login] = useAtom(loginAtom);

  return <div>Welcome {user?.name}</div>;
}

function UserName() {
  const [userName] = useAtom(userNameAtom);
  return <span>{userName}</span>;
}

Option 5: Recoil

Best for: Complex state with many dependencies

Pros:

  • Created by Facebook team
  • Powerful selectors
  • Async state handling
  • Time-travel debugging

Cons:

  • Still experimental
  • Requires Provider
  • Larger bundle size
import { atom, selector, useRecoilState, useRecoilValue } from 'recoil';

const userState = atom({
  key: 'userState',
  default: null,
});

const userNameState = selector({
  key: 'userNameState',
  get: ({ get }) => {
    const user = get(userState);
    return user?.name ?? 'Guest';
  },
});

// Async selector
const userPostsState = selector({
  key: 'userPostsState',
  get: async ({ get }) => {
    const user = get(userState);
    if (!user) return [];
    const posts = await fetchUserPosts(user.id);
    return posts;
  },
});

// Usage
import { RecoilRoot } from 'recoil';

function App() {
  return (
    <RecoilRoot>
      <Dashboard />
    </RecoilRoot>
  );
}

function Dashboard() {
  const [user, setUser] = useRecoilState(userState);
  const userName = useRecoilValue(userNameState);
  const posts = useRecoilValue(userPostsState);

  return <div>Welcome {userName}</div>;
}

Comparison Table

Feature Context API Redux Toolkit Zustand Jotai Recoil
Bundle Size 0KB ~12KB ~1KB ~3KB ~14KB
Learning Curve Easy Steep Easy Medium Medium
Boilerplate Medium High Low Low Medium
DevTools No Excellent Yes Basic Yes
Provider Needed Yes Yes No Optional Yes
TypeScript Good Excellent Excellent Excellent Good
Async Support Manual Built-in Manual Built-in Built-in
Performance Can be slow Optimized Optimized Optimized Optimized
Ecosystem Huge Huge Growing Small Medium

Decision Framework

Choose Context API if:

  • Building a small to medium app
  • Have simple global state (theme, auth)
  • Want zero dependencies
  • Don't need advanced features

Choose Redux Toolkit if:

  • Building a large, complex application
  • Need time-travel debugging
  • Want the largest ecosystem
  • Team already knows Redux
  • Need RTK Query for data fetching

Choose Zustand if:

  • Want simplicity without sacrificing features
  • Don't like Provider boilerplate
  • Need a lightweight solution
  • Want easy migration from Redux

Choose Jotai if:

  • Prefer atomic state approach
  • Building with React Suspense
  • Want fine-grained subscriptions
  • Need derived state

Choose Recoil if:

  • Need complex derived state
  • Working on a Facebook-style app
  • Want async state management
  • Okay with experimental status

Real-World Example: Shopping Cart

Let's implement a shopping cart with different approaches:

With Zustand (Recommended for most projects)

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

const useCartStore = create(
  persist(
    (set, get) => ({
      items: [],
      total: 0,

      addItem: (product) => set((state) => {
        const existing = state.items.find(item => item.id === product.id);
        if (existing) {
          return {
            items: state.items.map(item =>
              item.id === product.id
                ? { ...item, quantity: item.quantity + 1 }
                : item
            ),
          };
        }
        return {
          items: [...state.items, { ...product, quantity: 1 }],
        };
      }),

      removeItem: (productId) => set((state) => ({
        items: state.items.filter(item => item.id !== productId),
      })),

      updateQuantity: (productId, quantity) => set((state) => ({
        items: state.items.map(item =>
          item.id === productId ? { ...item, quantity } : item
        ),
      })),

      clearCart: () => set({ items: [] }),

      // Computed values
      get itemCount() {
        return get().items.reduce((sum, item) => sum + item.quantity, 0);
      },

      get total() {
        return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0);
      },
    }),
    {
      name: 'shopping-cart',
    }
  )
);

// Usage
function Cart() {
  const { items, total, removeItem, updateQuantity } = useCartStore();

  return (
    <div>
      {items.map(item => (
        <div key={item.id}>
          <span>{item.name}</span>
          <input
            type="number"
            value={item.quantity}
            onChange={(e) => updateQuantity(item.id, +e.target.value)}
          />
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}
      <div>Total: ${total.toFixed(2)}</div>
    </div>
  );
}

function CartButton() {
  const itemCount = useCartStore((state) => state.itemCount);
  return <button>Cart ({itemCount})</button>;
}

Performance Considerations

1. Avoid Unnecessary Re-renders

// ❌ Bad: Component re-renders on any state change
function UserProfile() {
  const state = useUserStore();
  return <div>{state.user.name}</div>;
}

// ✅ Good: Only re-renders when user.name changes
function UserProfile() {
  const userName = useUserStore((state) => state.user.name);
  return <div>{userName}</div>;
}

2. Split Large Stores

// ❌ Bad: One giant store
const useStore = create((set) => ({
  user: null,
  posts: [],
  comments: [],
  // ... 50 more properties
}));

// ✅ Good: Separate stores by domain
const useUserStore = create((set) => ({
  user: null,
  // user-related state
}));

const usePostsStore = create((set) => ({
  posts: [],
  // posts-related state
}));

3. Use Immer for Complex Updates

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

const useStore = create(
  immer((set) => ({
    nested: {
      deep: {
        data: []
      }
    },
    updateDeepData: (newData) => set((state) => {
      // Mutate draft directly (Immer handles immutability)
      state.nested.deep.data.push(newData);
    }),
  }))
);

Migration Strategies

From Context to Zustand

// Before: Context
const UserContext = createContext();

export function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

// After: Zustand
const useUserStore = create((set) => ({
  user: null,
  setUser: (user) => set({ user }),
}));

// No Provider needed! Just use the hook

From Redux to Zustand

// Before: Redux slice
const userSlice = createSlice({
  name: 'user',
  initialState: { data: null },
  reducers: {
    setUser: (state, action) => {
      state.data = action.payload;
    },
  },
});

// After: Zustand store
const useUserStore = create((set) => ({
  data: null,
  setUser: (data) => set({ data }),
}));

Conclusion

In 2024, state management in React is more flexible than ever:

  • Start simple: Use Context API for basic needs
  • Scale up: Move to Zustand when Context becomes limiting
  • Go comprehensive: Choose Redux Toolkit for enterprise applications
  • Think atomic: Try Jotai for bottom-up state architecture

My recommendation for most projects in 2024: Start with Zustand. It offers the best balance of simplicity, performance, and features. Only move to Redux Toolkit if you need its specific benefits (time-travel debugging, extensive middleware ecosystem).

The best state management solution is the one that solves your specific problems without adding unnecessary complexity. Start simple, measure performance, and evolve your approach as your application grows.

👨‍💻

Jordan Patel

Web Developer & Technology Enthusiast