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
| useState | Context API | Redux Toolkit | |
|---|---|---|---|
| Scope | One component | A subtree | The whole app |
| Setup cost | None | Small | A store, slices, a provider |
| Devtools | React Devtools | React Devtools | Time-travel debugging |
| Best for | Local UI state | Theme, auth, locale | Shared server data and complex flows |
| Re-render behaviour | The component | Every consumer | Only 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-redux2. 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.
Related Tools and Alternatives
- 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