Skip to content

React State Management: useState vs Redux vs Context, What to Use and When

useState vs Redux vs Context for React state management: what each is best for, a side-by-side comparison, and a guide to choosing.
GuideUpdated 5 min readDevelopmentReact JsRedux

Introduction: Why State Management Matters in React

React is known for its declarative, component-based architecture, but how you manage state determines the structure, performance, and scalability of your application.

Whether you’re building a simple to-do list or a full-scale dashboard, choosing the right state management tool can make or break your app’s user experience and developer efficiency.

In this article, we’ll compare the three most common React state management options:

  • useState
  • Context API
  • Redux

We’ll cover what they are, when to use them, and how to choose the right one based on your project’s complexity and scale.

What Is State in React?

In React, state refers to data that determines a component’s behaviour and rendering. It can be:

  • Local (used only within a component)
  • Global (shared across components)
  • Transient (temporary, UI-related)
  • Persistent (fetched from APIs or saved to storage)

As your app grows, so does your need to manage state in a predictable and maintainable way.

Option 1: useState, Best for Local State

The useState hook is React’s built-in way to manage local component state. It’s simple, lightweight, and ideal for isolated UI behaviours.

When to Use useState

  • Form inputs
  • Button toggles
  • Modal visibility
  • Scroll or tab state
  • Anything that stays within a single component
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}
  • Built into React, no setup required
  • Very easy to learn and use
  • Great for fast prototyping and small components
  • Not designed for sharing state between multiple components
  • Can become messy when passed through many levels (prop drilling)

Where useState Stops: Prop Drilling

Props pass data from a parent to its children, and they work well for one or two levels. When the same value has to reach a component several levels down, every component in between has to accept it and pass it on, even if it never uses it. This is called prop drilling. It makes components harder to move and change, and it is the usual sign that state needs to live somewhere shared: Context for simple values, Redux for larger apps.

Option 2: Context API, Best for Shared State (Lightweight)

The Context API allows you to create global state that can be accessed by any component without manually passing props down the tree.

It’s ideal for simple cross-app data like themes, language preferences, or authentication status.

When to Use Context

  • Theme (light/dark mode)
  • User authentication
  • Language (i18n)
  • App layout preferences
const ThemeContext = React.createContext();

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <MyComponent />
    </ThemeContext.Provider>
  );
}

function MyComponent() {
  const theme = useContext(ThemeContext);
  return <Text>{theme}</Text>;
}
  • No third-party library required
  • Clean for small-scale global state
  • Works well with hooks ( useContext )
  • Rerenders all consumers when state changes
  • Not ideal for complex or deeply nested updates
  • No built-in async handling or debugging tools

Option 3: Redux, Best for Complex, Scalable Applications

Redux is an external state management library used for complex React apps with multiple shared states and advanced logic.

When to Use Redux

  • Large applications with deeply shared state
  • Apps with complex business logic
  • Applications requiring middleware (e.g., logging, analytics, caching)
  • API-driven apps with multiple async flows
dispatch({ type: 'INCREMENT_COUNTER' });
  • Predictable and scalable
  • Supports middleware like redux-thunk, redux-saga
  • Powerful devtools with time-travel debugging
  • Well-documented and battle-tested
  • Steeper learning curve for beginners
  • More boilerplate code (less with Redux Toolkit)
  • Might be overkill for small projects

Side-by-Side Comparison

useStateContext APIRedux Toolkit
ScopeOne componentA subtreeThe whole app
Setup costNoneSmallA store, slices, a provider
DevtoolsReact DevtoolsReact DevtoolsTime-travel debugging
Best forLocal UI stateTheme, auth, localeShared server data and complex flows
Re-render behaviourThe componentEvery consumerOnly subscribed selectors

Most applications need all three, used for different things, rather than one of them used for everything. The order to adopt them in is the order of the columns: start local, lift to context when a value is genuinely shared, and reach for Redux when you can name the problem it solves for you.

How to Choose: Decision Guide

  • Small component state? → useState
  • Simple shared state (like theme)? → Context
  • Large app with APIs & async logic? → Redux
  • useState for local UI
  • Context for layout/auth
  • Redux for API and async global data

Pro Tip: Use Redux Toolkit

If Redux seems too heavy, use Redux Toolkit (RTK), the modern and official way to write Redux code.

  • createSlice for simpler reducers
  • Built-in devtools support
  • Built-in createAsyncThunk for handling APIs

The difference is easiest to see side by side. A classic Redux reducer:

function counterReducer(state = { value: 0 }, action) {
  switch (action.type) {
    case 'increment':
      return { value: state.value + 1 };
    default:
      return state;
  }
}

The same thing with Redux Toolkit. No switch statement and no action type constants, and you can write state.value += 1 safely because Redux Toolkit uses Immer to turn it into an immutable update:

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
  },
});

Setting Up Redux Toolkit

1. Install it with React-Redux:

npm install @reduxjs/toolkit react-redux

2. Create a slice. The actions are generated for you:

// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  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;

3. Configure the store. Redux DevTools support is switched on by default:

// app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

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

4. Wrap the app in a Provider, using createRoot:

// index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

5. Read state with useSelector and update it with useDispatch:

import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from './features/counter/counterSlice';

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

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

Older tutorials set Redux up with createStore and ReactDOM.render. Avoid both in new code: createStore is marked deprecated in favour of Redux Toolkit's configureStore, and ReactDOM.render was removed in React 19.

Loading Data with createAsyncThunk

createAsyncThunk handles API calls and generates pending, fulfilled and rejected actions, so you do not write the loading logic by hand:

// features/users/usersSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
  const response = await fetch('/api/users');
  return await response.json();
});

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

export default usersSlice.reducer;

Redux Toolkit is fully typed, so it works well with TypeScript, and it is still Redux: the same store, actions and DevTools, with less code.

  • Zustand: Minimal state management with hooks
  • Recoil: Atom-based state from Meta, now archived and no longer maintained, so avoid it for new projects
  • Jotai: Atomic state for React
  • MobX: Observable-driven reactivity

Final Thoughts: State Management in React Doesn’t Have to Be Hard

React gives you options, from local to global, from simple to powerful.

You don’t need to over-engineer your app. Start small, scale gradually, and adopt tools like Context or Redux only when needed.

  • useState is still perfect for small UI components
  • Context works great for lightweight global state
  • Redux (with Toolkit) remains the best option for complex apps
Share this
Build work

Need this built properly?

I build the web and mobile products these notes come out of, in React, React Native and Node. A first conversation costs nothing and often ends with a smaller scope than you expected.

Not ready to book a call? Get a free website health check