VS
Frontend

React State Management: Redux vs Context API

Mayur Dabhi
Mayur Dabhi
March 3, 2026
22 min read

You add a logged-in user to App, and a week later that user prop is being passed through five components that never read it, just so an avatar in the header can show a name. Someone suggests Redux. Someone else says "just use Context." Both are reasonable answers, and picking the wrong one usually shows up later as either a pile of boilerplate or a page that re-renders on every keystroke.

This post compares Redux and the Context API using the same auth example in both, then looks at re-render behavior, a decision checklist, and the mistakes I see most often with each.

What's covered
  • How Context and Redux actually move data to components
  • Why one re-renders more than the other
  • A working auth implementation in each
  • A quick way to decide between them
  • Pitfalls and the error messages they produce

Understanding the State Management Problem

React data flows one way: down through props. That's fine for a handful of components. Once the tree gets deeper, a few problems start showing up:

Prop Drilling Problem With State Management App (state) props Layout Sidebar Content UserInfo UserData ❌ Props passed through every level App STORE user cart settings Layout Sidebar Content UserInfo UserData ✓ Direct access to global state

Prop drilling vs centralized state management: Components access state directly without intermediate props

Common State Management Challenges

React Context API: Built-in Solution

Context ships with React, so there's nothing to install. The current API arrived in React 16.3, and once useContext landed with hooks in 16.8 it got much nicer to consume. Strictly speaking, Context isn't a state manager at all. It's a way to hand a value down the tree. The state itself still lives in useState or useReducer.

What It Provides

A way to pass data through the component tree without manually passing props at every level.

Best For

Theme data, user authentication, language preferences, and infrequently changing global state.

Context API Architecture

Context API Data Flow Context.Provider value={{ state, dispatch }} Component A useContext() Component C useContext() = Consumer (has access)

Context provides direct access to state for any nested component that needs it

Implementation: Context API with useReducer

For anything beyond a single value, I pair Context with useReducer. Here's an auth context built that way:

UserContext.jsx
import { createContext, useContext, useReducer, useMemo } from 'react';

// Initial state
const initialState = {
  user: null,
  isAuthenticated: false,
  isLoading: false,
  error: null,
};

// Action types
const ActionTypes = {
  LOGIN_START: 'LOGIN_START',
  LOGIN_SUCCESS: 'LOGIN_SUCCESS',
  LOGIN_FAILURE: 'LOGIN_FAILURE',
  LOGOUT: 'LOGOUT',
  UPDATE_USER: 'UPDATE_USER',
};

// Reducer function
function userReducer(state, action) {
  switch (action.type) {
    case ActionTypes.LOGIN_START:
      return { ...state, isLoading: true, error: null };
    
    case ActionTypes.LOGIN_SUCCESS:
      return {
        ...state,
        user: action.payload,
        isAuthenticated: true,
        isLoading: false,
        error: null,
      };
    
    case ActionTypes.LOGIN_FAILURE:
      return {
        ...state,
        user: null,
        isAuthenticated: false,
        isLoading: false,
        error: action.payload,
      };
    
    case ActionTypes.LOGOUT:
      return { ...initialState };
    
    case ActionTypes.UPDATE_USER:
      return { ...state, user: { ...state.user, ...action.payload } };
    
    default:
      throw new Error(`Unknown action type: ${action.type}`);
  }
}

// Create contexts
const UserStateContext = createContext(null);
const UserDispatchContext = createContext(null);

// Provider component
export function UserProvider({ children }) {
  const [state, dispatch] = useReducer(userReducer, initialState);
  
  // Memoize to prevent unnecessary re-renders
  const stateValue = useMemo(() => state, [state]);
  const dispatchValue = useMemo(() => dispatch, []);
  
  return (
    <UserStateContext.Provider value={stateValue}>
      <UserDispatchContext.Provider value={dispatchValue}>
        {children}
      </UserDispatchContext.Provider>
    </UserStateContext.Provider>
  );
}

// Custom hooks for consuming context
export function useUserState() {
  const context = useContext(UserStateContext);
  if (context === null) {
    throw new Error('useUserState must be used within a UserProvider');
  }
  return context;
}

export function useUserDispatch() {
  const context = useContext(UserDispatchContext);
  if (context === null) {
    throw new Error('useUserDispatch must be used within a UserProvider');
  }
  return context;
}

// Convenience hook for both
export function useUser() {
  return [useUserState(), useUserDispatch()];
}

// Action creators
export const userActions = {
  loginStart: () => ({ type: ActionTypes.LOGIN_START }),
  loginSuccess: (user) => ({ type: ActionTypes.LOGIN_SUCCESS, payload: user }),
  loginFailure: (error) => ({ type: ActionTypes.LOGIN_FAILURE, payload: error }),
  logout: () => ({ type: ActionTypes.LOGOUT }),
  updateUser: (data) => ({ type: ActionTypes.UPDATE_USER, payload: data }),
};
Using the Context
// App.jsx - Wrap your app with the provider
import { UserProvider } from './context/UserContext';

function App() {
  return (
    <UserProvider>
      <Router>
        <Header />
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/profile" element={<Profile />} />
        </Routes>
      </Router>
    </UserProvider>
  );
}

// Header.jsx - Consuming the context
import { useUserState } from './context/UserContext';

function Header() {
  const { user, isAuthenticated } = useUserState();
  
  return (
    <header>
      <nav>
        {isAuthenticated ? (
          <span>Welcome, {user.name}!</span>
        ) : (
          <LoginButton />
        )}
      </nav>
    </header>
  );
}

// LoginForm.jsx - Dispatching actions
import { useUserDispatch, userActions } from './context/UserContext';

function LoginForm() {
  const dispatch = useUserDispatch();
  
  const handleLogin = async (credentials) => {
    dispatch(userActions.loginStart());
    
    try {
      const response = await api.login(credentials);
      dispatch(userActions.loginSuccess(response.user));
    } catch (error) {
      dispatch(userActions.loginFailure(error.message));
    }
  };
  
  return (
    <form onSubmit={(e) => { e.preventDefault(); handleLogin(formData); }}>
      {/* form fields */}
    </form>
  );
}
Why two contexts?

State and dispatch live in separate contexts on purpose. dispatch from useReducer never changes identity, so a component like LoginForm that only dispatches won't re-render when the user object changes.

Redux: A Store Outside React

Redux has been around since 2015, and most of its bad reputation comes from the early days of hand-written action types and switch statements. Redux Toolkit (RTK) removed most of that. The core idea hasn't changed: state lives in one store outside the component tree, and components subscribe to just the pieces they need.

Single Source of Truth

All application state lives in one store, making debugging and state inspection straightforward.

Time Travel Debugging

Redux DevTools let you step back and forth through every action and see the state diff for each one.

Middleware Support

Middleware sits between dispatch and the reducer, which is where async logic, logging, and analytics hooks go.

Predictable Updates

State can only change through actions, making updates traceable and testable.

Redux Architecture

Redux Unidirectional Data Flow STORE { state } VIEW (UI) React Components ACTION { type, payload } REDUCER (state, action) subscribe dispatch new state Action → Reducer → Store → View → (user interaction) → Action

Every change goes through the same loop, so you can always trace which action produced which state

Implementation: Redux Toolkit

RTK is what the Redux docs tell you to use now. createSlice generates action creators for you, Immer lets you write "mutating" code that produces immutable updates, and configureStore wires up thunks and DevTools without extra setup. Here's the same auth logic:

store/userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { api } from '../services/api';

// Async thunk for login
export const loginUser = createAsyncThunk(
  'user/login',
  async (credentials, { rejectWithValue }) => {
    try {
      const response = await api.login(credentials);
      // Store token in localStorage
      localStorage.setItem('token', response.token);
      return response.user;
    } catch (error) {
      return rejectWithValue(error.response?.data?.message || 'Login failed');
    }
  }
);

// Async thunk for fetching user profile
export const fetchUserProfile = createAsyncThunk(
  'user/fetchProfile',
  async (_, { rejectWithValue }) => {
    try {
      const response = await api.getProfile();
      return response.user;
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

const initialState = {
  user: null,
  isAuthenticated: false,
  isLoading: false,
  error: null,
};

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    logout: (state) => {
      localStorage.removeItem('token');
      state.user = null;
      state.isAuthenticated = false;
      state.error = null;
    },
    updateUser: (state, action) => {
      state.user = { ...state.user, ...action.payload };
    },
    clearError: (state) => {
      state.error = null;
    },
  },
  extraReducers: (builder) => {
    builder
      // Login
      .addCase(loginUser.pending, (state) => {
        state.isLoading = true;
        state.error = null;
      })
      .addCase(loginUser.fulfilled, (state, action) => {
        state.isLoading = false;
        state.user = action.payload;
        state.isAuthenticated = true;
      })
      .addCase(loginUser.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.payload;
      })
      // Fetch profile
      .addCase(fetchUserProfile.pending, (state) => {
        state.isLoading = true;
      })
      .addCase(fetchUserProfile.fulfilled, (state, action) => {
        state.isLoading = false;
        state.user = action.payload;
        state.isAuthenticated = true;
      })
      .addCase(fetchUserProfile.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.payload;
      });
  },
});

export const { logout, updateUser, clearError } = userSlice.actions;

// Selectors
export const selectUser = (state) => state.user.user;
export const selectIsAuthenticated = (state) => state.user.isAuthenticated;
export const selectIsLoading = (state) => state.user.isLoading;
export const selectError = (state) => state.user.error;

export default userSlice.reducer;
store/index.js
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
import cartReducer from './cartSlice';
import notificationReducer from './notificationSlice';

export const store = configureStore({
  reducer: {
    user: userReducer,
    cart: cartReducer,
    notifications: notificationReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        // Ignore these action types
        ignoredActions: ['persist/PERSIST'],
      },
    }),
  devTools: process.env.NODE_ENV !== 'production',
});

// TypeScript types (if using TS)
// export type RootState = ReturnType<typeof store.getState>;
// export type AppDispatch = typeof store.dispatch;
Using Redux in Components
// App.jsx
import { Provider } from 'react-redux';
import { store } from './store';

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

// Header.jsx - Reading state with useSelector
import { useSelector } from 'react-redux';
import { selectUser, selectIsAuthenticated } from './store/userSlice';

function Header() {
  const user = useSelector(selectUser);
  const isAuthenticated = useSelector(selectIsAuthenticated);
  
  return (
    <header>
      {isAuthenticated ? (
        <UserMenu user={user} />
      ) : (
        <LoginButton />
      )}
    </header>
  );
}

// LoginForm.jsx - Dispatching actions
import { useDispatch, useSelector } from 'react-redux';
import { loginUser, selectIsLoading, selectError, clearError } from './store/userSlice';

function LoginForm() {
  const dispatch = useDispatch();
  const isLoading = useSelector(selectIsLoading);
  const error = useSelector(selectError);
  const [formData, setFormData] = useState({ email: '', password: '' });
  
  const handleSubmit = (e) => {
    e.preventDefault();
    dispatch(loginUser(formData));
  };
  
  useEffect(() => {
    // Clear error when component unmounts
    return () => dispatch(clearError());
  }, [dispatch]);
  
  return (
    <form onSubmit={handleSubmit}>
      {error && <div className="error">{error}</div>}
      <input
        type="email"
        value={formData.email}
        onChange={(e) => setFormData({ ...formData, email: e.target.value })}
        disabled={isLoading}
      />
      <input
        type="password"
        value={formData.password}
        onChange={(e) => setFormData({ ...formData, password: e.target.value })}
        disabled={isLoading}
      />
      <button type="submit" disabled={isLoading}>
        {isLoading ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
}

Head-to-Head Comparison

Side by side, the differences look like this:

Feature Context API Redux
Setup Complexity Minimal, built into React Moderate, needs @reduxjs/toolkit and react-redux
Bundle Size 0 KB (part of React) ~10-15 KB (with RTK)
Learning Curve Low, plain React patterns Medium: slices, thunks, selectors
DevTools React DevTools only Redux DevTools with action log and time travel
Middleware Not built-in Built-in middleware pipeline
Async Operations Manual implementation Built-in with createAsyncThunk
Re-render Control All consumers re-render Fine-grained with selectors
State Structure Flexible, multiple contexts Single store, organized by slices
Testing Requires wrapper setup Easy with isolated reducers
TypeScript Support Good Excellent

Performance Comparison

This is where the two really differ, and it's usually what pushes a project from one to the other.

Context API Performance Characteristics

Re-render EfficiencyLimited

When the context value changes, every consumer re-renders, even one that only reads isLoading while you changed user.name. There's no built-in way to subscribe to part of a context. You can split contexts or wrap children in memo, but that's work you do by hand.

Redux Performance Characteristics

Re-render EfficiencyExcellent

Components only re-render when their selected state changes. useSelector compares the selected value with === after each dispatch and skips the render if it's the same. For derived data, createSelector memoizes the result so you don't return a fresh object every time.

Redux Memoized Selectors
import { createSelector } from '@reduxjs/toolkit';

// Basic selectors
const selectCartItems = (state) => state.cart.items;
const selectTaxRate = (state) => state.settings.taxRate;

// Memoized selector - only recalculates when dependencies change
export const selectCartTotal = createSelector(
  [selectCartItems, selectTaxRate],
  (items, taxRate) => {
    const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    const tax = subtotal * taxRate;
    return {
      subtotal,
      tax,
      total: subtotal + tax,
      itemCount: items.reduce((count, item) => count + item.quantity, 0),
    };
  }
);

// Usage - component only re-renders when cart total actually changes
function CartSummary() {
  const { subtotal, tax, total, itemCount } = useSelector(selectCartTotal);
  // This component won't re-render for unrelated state changes
}

Decision Framework: Which Should You Choose?

I run through these questions in roughly this order:

🤔 How complex is your application's state?
Simple to moderate: theme, auth, user preferences → Context API
🔄 Do you need to share state across many components?
Yes, and it updates often: shopping cart, real-time data → Redux
🐛 Will you need to trace how state got into a bad shape?
Yes: large team, multi-step flows, bugs reported from production → Redux
⚡ Do you have performance-sensitive UI updates?
Yes: long lists, live updates, values that change on every keystroke → Redux

Practical Recommendations

Use Context API When:

  • State changes infrequently (theme, locale)
  • Data is needed by many components
  • You want minimal setup
  • Your team is new to React
  • Building a small to medium app
  • Prototyping or MVP stage

Use Redux When:

  • Complex state with many actions
  • State updates frequently
  • You need time-travel debugging
  • Multiple developers on the team
  • Complex async operations
  • State needs to be serializable
Hybrid Approach

You don't have to pick one. Plenty of apps use both:

  • Context for theme, authentication, and UI preferences
  • Redux for complex domain state like shopping carts, user data, and API caches

Context Performance: Avoiding Unnecessary Re-renders

This is where most Context setups go wrong. When the context value changes, every component that consumes it re-renders, even if the piece it actually uses is the same as before. Three things help.

Strategy 1: Split Contexts by Update Frequency

Don't keep state that changes constantly in the same context as state that almost never changes. A consumer only re-renders when its own context changes:

Split contexts by frequency
// BAD: one context for everything, so all consumers re-render on any change
const AppContext = createContext({ user, theme, cart, notifications });

// GOOD: separate contexts, so components only subscribe to what they use
const UserContext = createContext(null);        // Changes rarely
const ThemeContext = createContext(null);       // Changes on user toggle
const CartContext = createContext(null);        // Changes frequently
const NotificationsContext = createContext(null); // Changes on events

Strategy 2: Memoize the Context Value

When the Provider component re-renders for any reason, an inline value={{ ... }} object is brand new, so React treats it as changed and re-renders every consumer. useMemo keeps the same object until its dependencies change:

Memoizing context value
import { createContext, useContext, useState, useMemo } from 'react';

const CartContext = createContext(null);

export function CartProvider({ children }) {
  const [items, setItems] = useState([]);

  const addItem = (product) =>
    setItems(prev => [...prev, product]);

  const removeItem = (id) =>
    setItems(prev => prev.filter(item => item.id !== id));

  const total = items.reduce((sum, item) => sum + item.price, 0);

  // Without useMemo, { items, addItem, removeItem, total } is a new
  // object reference on every render, re-rendering all consumers.
  const value = useMemo(
    () => ({ items, addItem, removeItem, total }),
    [items] // Only recreate when items actually changes
  );

  return (
    <CartContext.Provider value={value}>
      {children}
    </CartContext.Provider>
  );
}

export const useCart = () => useContext(CartContext);

Strategy 3: Separate State and Dispatch

With useReducer, you can put state and dispatch in two different contexts. dispatch is stable across renders, so components that only send actions (buttons, mostly) stop re-rendering when the state changes:

useReducer with split contexts
import { createContext, useContext, useReducer } from 'react';

const CountStateContext = createContext(null);
const CountDispatchContext = createContext(null);

function countReducer(state, action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1 };
    case 'decrement': return { count: state.count - 1 };
    case 'reset':     return { count: 0 };
    default: throw new Error(`Unknown action: ${action.type}`);
  }
}

export function CountProvider({ children }) {
  const [state, dispatch] = useReducer(countReducer, { count: 0 });

  return (
    <CountStateContext.Provider value={state}>
      <CountDispatchContext.Provider value={dispatch}>
        {children}
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  );
}

// Components that display count: re-render when count changes
export const useCountState = () => useContext(CountStateContext);

// Components that only dispatch: never re-render due to count changes
export const useCountDispatch = () => useContext(CountDispatchContext);

// Usage in a display component:
function Counter() {
  const { count } = useCountState();
  return <h1>Count: {count}</h1>;
}

// Usage in a button component: won't re-render when count changes!
function IncrementButton() {
  const dispatch = useCountDispatch();
  return <button onClick={() => dispatch({ type: 'increment' })}>+</button>;
}

Composing Multiple Providers

Once you have four or five contexts, the nesting in main.jsx gets ugly. I move it into one AppProviders component:

src/providers/AppProviders.jsx
import { AuthProvider } from '../context/AuthContext';
import { ThemeProvider } from '../context/ThemeContext';
import { CartProvider } from '../context/CartContext';
import { NotificationsProvider } from '../context/NotificationsContext';

// Compose providers into a single wrapper. Order matters!
// Outer providers are available to inner providers.
export function AppProviders({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        <NotificationsProvider>
          <CartProvider>
            {children}
          </CartProvider>
        </NotificationsProvider>
      </AuthProvider>
    </ThemeProvider>
  );
}
src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AppProviders } from './providers/AppProviders';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <BrowserRouter>
      <AppProviders>
        <App />
      </AppProviders>
    </BrowserRouter>
  </React.StrictMode>
);

Best Practices for Both Approaches

Context API Best Practices

1

Split Context by Domain

Create separate contexts for unrelated data (UserContext, ThemeContext, SettingsContext) to prevent unnecessary re-renders.

2

Separate State and Dispatch

Use two contexts (one for state, one for dispatch) so components that only dispatch don't re-render on state changes.

3

Memoize Context Values

If you pass an object like { state, dispatch }, wrap it in useMemo. Otherwise it's a new reference on every provider render and every consumer re-renders with it.

4

Create Custom Hooks

Wrap useContext in hooks like useUser() that throw when there's no provider. Without that check you get null back and a confusing "Cannot read properties of null" crash somewhere far from the real cause.

Redux Best Practices

1

Use Redux Toolkit

Don't start a new project with hand-written Redux. RTK includes Immer for immutable updates, configureStore with sane defaults, and createSlice for less boilerplate.

2

Normalize State Shape

Store data in a flat, normalized structure with IDs for efficient lookups and updates.

3

Use Selectors

Read state through named selectors so a change in state shape only touches one file. Use createSelector for computed values.

4

Keep Reducers Pure

No side effects in reducers. Use middleware (thunks, sagas) for async operations.

Common Pitfalls to Avoid

Context API Pitfalls
  • Putting everything in one context: every consumer re-renders on any change, so a modal toggle re-renders your whole header.
  • Creating the context value inline: value={{ user, setUser }} is a new object every render.
  • Relying on the default value: a component rendered outside the provider silently gets the createContext default instead of an error. This bites in tests and Storybook.
  • Using it for state that changes constantly: form inputs or mouse position in context means re-rendering every consumer on every event.
Redux Pitfalls
  • Mutating state outside createSlice: Immer only protects you inside slice reducers. Mutating state in a selector or component still causes stale UI.
  • Storing non-serializable values: Dates, Promises, or class instances in state trigger the console warning "A non-serializable value was detected in the state" and confuse DevTools.
  • Over-selecting: useSelector(state => state) or returning a new object from a selector re-renders on every dispatch.
  • Side effects in reducers: API calls and dispatches belong in thunks or middleware.
A common error in tests

If a test renders a component that calls useSelector without wrapping it in <Provider store={store}>, react-redux throws "could not find react-redux context value; please ensure the component is wrapped in a <Provider>". The fix is a small renderWithProviders helper that creates a fresh store per test, so state doesn't leak between tests.

When I'd Pick Each (and When I Wouldn't)

I start with Context for auth, theme, and locale. These values change a few times per session, so the "every consumer re-renders" cost doesn't matter. I'd skip Redux entirely for a small app or a marketing site with a login button. Two extra packages and a store file buy you nothing there.

I move to Redux when I notice I'm building it by hand: several reducers in different contexts, custom memo tricks to stop re-renders, or a "why is this value wrong" bug I can't trace. Redux's action log is what you want in that situation.

Before reaching for either, check whether the state is really server data. If most of your "global state" is API responses with loading and error flags (like the auth example here), a data fetching library such as RTK Query or TanStack Query handles caching and refetching better than a hand-written slice or reducer. Likewise, don't move form state into either one. Local useState is still the right default for anything only one component tree cares about.

React Redux Context API State Management Hooks Frontend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Next.js. Passionate about clean code and developer experience.