Welcome to Part 11 and the final article of the React Mastery Series!
As React applications grow, managing state becomes one of the most important challenges developers face.
At first, useState is usually enough. But what happens when multiple components need access to the same data? What if you have authentication, shopping carts, themes, notifications, filters, or complex application workflows?
That's where state management comes in.
In this article, we'll explore the most important state management approaches in React:
- React's built-in state management
- Lifting state up and prop drilling
- Context API
-
useReducerwith Context - Redux Toolkit
- Zustand
- Best practices and common mistakes
- How to choose the right solution
By the end, you'll understand when to use each approach and how to build maintainable state management for real-world applications.
1. Understanding State Management
State represents data that can change over time and affect what your application displays.
For example:
const [count, setCount] = useState(0); When count changes, React updates the components that depend on it.
However, not all state belongs in the same place.
Local State
Local state is used by a single component or a small group of closely related components.
Examples:
- Input values
- Dropdown visibility
- Modal visibility
- Selected tabs
- Temporary form data
const [isOpen, setIsOpen] = useState(false); Shared or Global Client State
Shared state is needed by multiple components across different parts of the application.
Examples:
- Current theme
- Shopping cart
- Authentication UI state
- User preferences
- Global notifications
Server State
Server state represents data that comes from an external source, such as an API.
Examples:
- User profiles
- Product lists
- Orders
- Comments
- Dashboard statistics
Server state has additional challenges:
- Caching
- Loading and error states
- Refetching
- Synchronization
- Pagination
- Stale data
Libraries such as TanStack Query and SWR are designed to handle these problems.
An important distinction:
Redux Toolkit, Zustand, and Context API can manage client-side application state. TanStack Query and SWR specialize in managing remote server data.
You don't necessarily need one tool to manage every kind of state.
2. React's Built-in State Management
Before installing an external library, understand the tools React already provides.
The most common options are:
useStateuseReduceruseContext
useState
Use useState for simple state.
import { useState } from "react"; function Counter() { const [count, setCount] = useState(0); return ( <div> <h2>Count: {count}</h2> <button onClick={() => setCount(count + 1)}> Increment </button> </div> ); } export default Counter; This is perfect when the state belongs to one component.
But what if several components need the same value?
That's where lifting state up becomes useful.
3. Lifting State Up and Prop Drilling
Imagine two components need access to the same username.
You can move the state to their closest common parent and pass it down through props.
function Parent() { const [username, setUsername] = useState("Alex"); return ( <> <Profile username={username} /> <Welcome username={username} /> </> ); } function Profile({ username }: { username: string }) { return <h2>Profile: {username}</h2>; } function Welcome({ username }: { username: string }) { return <h2>Welcome, {username}!</h2>; } This technique is called lifting state up.
It works well for small component trees.
What Is Prop Drilling?
Prop drilling happens when you pass data through multiple components that don't actually need it.
function App() { const username = "Alex"; return <Layout username={username} />; } function Layout({ username }: { username: string }) { return <Sidebar username={username} />; } function Sidebar({ username }: { username: string }) { return <UserProfile username={username} />; } function UserProfile({ username }: { username: string }) { return <h2>{username}</h2>; } As applications grow, this can become difficult to maintain.
One solution is Context API.
4. Context API
Context API is built into React and allows components to share values without passing props through every intermediate component.
It's useful for:
- Themes
- Authentication information
- Language preferences
- Application settings
- Other relatively stable shared values
Step 1: Create a Context
import { createContext } from "react"; type ThemeContextValue = { theme: "light" | "dark"; toggleTheme: () => void; }; export const ThemeContext = createContext<ThemeContextValue | undefined>(undefined); We use undefined as the default value so we can detect when a component is rendered outside its provider.
Step 2: Create a Provider
import { createContext, useCallback, useMemo, useState, type ReactNode, } from "react"; type ThemeContextValue = { theme: "light" | "dark"; toggleTheme: () => void; }; const ThemeContext = createContext<ThemeContextValue | undefined>(undefined); type ThemeProviderProps = { children: ReactNode; }; export function ThemeProvider({ children, }: ThemeProviderProps) { const [theme, setTheme] = useState<"light" | "dark">("light"); const toggleTheme = useCallback(() => { setTheme((current) => current === "light" ? "dark" : "light" ); }, []); const value = useMemo( () => ({ theme, toggleTheme }), [theme, toggleTheme] ); return ( <ThemeContext.Provider value={value}> {children} </ThemeContext.Provider> ); } The useCallback and useMemo calls help keep the provider value stable when its dependencies haven't changed.
They don't prevent consumers from updating when the actual context value changes.
Step 3: Create a Custom Hook
import { useContext } from "react"; export function useTheme() { const context = useContext(ThemeContext); if (!context) { throw new Error( "useTheme must be used within ThemeProvider" ); } return context; } This makes Context easier and safer to use.
Step 4: Use the Context
function ThemeButton() { const { theme, toggleTheme } = useTheme(); return ( <button onClick={toggleTheme}> Current theme: {theme} </button> ); } Step 5: Wrap Your Application
import { ThemeProvider } from "./ThemeContext"; function App() { return ( <ThemeProvider> <MainLayout /> </ThemeProvider> ); } Now any component inside ThemeProvider can access the theme.
Context API Limitations
Context is useful, but it isn't automatically a complete state management solution.
One important limitation is that consumers of a context can re-render when its provider value changes.
For larger applications, consider:
- Splitting unrelated contexts into separate providers.
- Keeping provider values stable when their dependencies haven't changed.
- Avoiding putting frequently changing, unrelated data into one large context.
- Using a state management library when you need more advanced subscriptions, selectors, or debugging tools.
Rule of thumb: Context is excellent for sharing values. It doesn't automatically provide all the features of a dedicated state management library.
5. useReducer with Context
When state becomes more complex, useReducer can provide a more structured approach than multiple useState calls.
A reducer receives the current state and an action, then returns the next state.
Example: Counter Reducer
import { useReducer } from "react"; type State = { count: number; }; type Action = | { type: "increment" } | { type: "decrement" } | { type: "reset" }; const initialState: State = { count: 0, }; function counterReducer( state: State, action: Action ): State { switch (action.type) { case "increment": return { count: state.count + 1 }; case "decrement": return { count: state.count - 1 }; case "reset": return initialState; default: return state; } } function Counter() { const [state, dispatch] = useReducer( counterReducer, initialState ); return ( <div> <h2>{state.count}</h2> <button onClick={() => dispatch({ type: "increment" })}> + </button> <button onClick={() => dispatch({ type: "decrement" })}> - </button> <button onClick={() => dispatch({ type: "reset" })}> Reset </button> </div> ); } Instead of directly describing how to update state in every event handler, you dispatch actions describing what happened.
Combining useReducer with Context
You can expose both the state and dispatch function through Context.
import { createContext, useContext, useReducer, type ReactNode, } from "react"; type State = { count: number; }; type Action = | { type: "increment" } | { type: "decrement" }; function reducer(state: State, action: Action): State { switch (action.type) { case "increment": return { count: state.count + 1 }; case "decrement": return { count: state.count - 1 }; default: return state; } } const CounterStateContext = createContext<State | undefined>( undefined ); const CounterDispatchContext = createContext<React.Dispatch<Action> | undefined>(undefined); export function CounterProvider({ children, }: { children: ReactNode; }) { const [state, dispatch] = useReducer(reducer, { count: 0, }); return ( <CounterStateContext.Provider value={state}> <CounterDispatchContext.Provider value={dispatch}> {children} </CounterDispatchContext.Provider> </CounterStateContext.Provider> ); } export function useCounterState() { const context = useContext(CounterStateContext); if (!context) { throw new Error("useCounterState requires CounterProvider"); } return context; } export function useCounterDispatch() { const context = useContext(CounterDispatchContext); if (!context) { throw new Error("useCounterDispatch requires CounterProvider"); } return context; } Separating state and dispatch contexts can help components that only need to dispatch actions avoid subscribing to the changing state context.
However, components reading the state context still update when that context changes.
When should you use useReducer with Context?
- When multiple components share structured state.
- When updates involve several related actions.
- When you want predictable update logic.
- When you don't need the full ecosystem of an external library.
6. Redux Toolkit
Redux is a predictable state management library that centralizes application state in a store.
It is commonly used in large applications where predictable updates, debugging, middleware, and a mature ecosystem are important.
Today, Redux Toolkit (RTK) is the recommended approach for writing Redux logic.
It simplifies Redux and removes much of the repetitive code associated with older Redux patterns.
Install Redux Toolkit
npm install @reduxjs/toolkit react-redux Redux Toolkit Core Concepts
| Concept | Purpose |
|---|---|
| Store | Holds the application state |
| Slice | Defines a feature's state and reducers |
| Action | Describes something that happened |
| Reducer | Calculates the next state |
| Dispatch | Sends an action to the store |
| Selector | Reads data from the store |
Step 1: Create a Slice
Imagine we're building a shopping cart.
// features/cart/cartSlice.ts import { createSlice, type PayloadAction, } from "@reduxjs/toolkit"; type CartItem = { id: number; name: string; price: number; quantity: number; }; type CartState = { items: CartItem[]; }; const initialState: CartState = { items: [], }; const cartSlice = createSlice({ name: "cart", initialState, reducers: { addItem: (state, action: PayloadAction<CartItem>) => { const existing = state.items.find( (item) => item.id === action.payload.id ); if (existing) { existing.quantity += action.payload.quantity; } else { state.items.push(action.payload); } }, removeItem: (state, action: PayloadAction<number>) => { state.items = state.items.filter( (item) => item.id !== action.payload ); }, clearCart: (state) => { state.items = []; }, }, }); export const { addItem, removeItem, clearCart, } = cartSlice.actions; export default cartSlice.reducer; Notice that we're using syntax that looks like direct mutation:
existing.quantity += action.payload.quantity; Why is this safe?
Redux Toolkit uses Immer internally. Immer tracks these changes and produces the next immutable state.
This behavior is safe inside Redux Toolkit's Immer-managed reducers. It does not mean you should mutate Redux state directly elsewhere.
Step 2: Configure the Store
// app/store.ts import { configureStore } from "@reduxjs/toolkit"; import cartReducer from "../features/cart/cartSlice"; export const store = configureStore({ reducer: { cart: cartReducer, }, }); export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch; The store combines your application's reducers and provides a central place for state.
Step 3: Provide the Store to React
// main.tsx import React from "react"; import ReactDOM from "react-dom/client"; import { Provider } from "react-redux"; import { store } from "./app/store"; import App from "./App"; ReactDOM.createRoot( document.getElementById("root")! ).render( <Provider store={store}> <App /> </Provider> ); Step 4: Create Typed Redux Hooks
When using TypeScript, typed hooks make Redux much easier to work with.
// app/hooks.ts import { useDispatch, useSelector, type TypedUseSelectorHook, } from "react-redux"; import type { RootState, AppDispatch, } from "./store"Source:https://dev.to/hamedfarazi/react-mastery-series-part-11-state-management-in-react-context-api-redux-toolkit-zustand-21k7