Intermediate
Context & Custom Hooks
As an app grows, you need better ways to share data, organize state updates and reuse logic between components.
The problem: prop drilling
Passing props works well for a level or two. But when a deeply nested component needs something like the current theme or the logged-in user, every component in between has to accept that prop and pass it on, even if it never uses it. This is called prop drilling.
Context
Context lets a parent make a value available to every component beneath it, however deep, without passing it through props.
1. Create the context
// ThemeContext.js
import { createContext } from "react";
export const ThemeContext = createContext("light"); // default value
2. Provide a value
import { useState } from "react";
import { ThemeContext } from "./ThemeContext";
function App() {
const [theme, setTheme] = useState("light");
return (
<ThemeContext value={theme}>
<Page />
<button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
Toggle theme
</button>
</ThemeContext>
);
}
Since React 19 you can render the context itself as the provider. In older versions, write <ThemeContext.Provider value={theme}>.
3. Read it anywhere below
import { useContext } from "react";
import { ThemeContext } from "./ThemeContext";
function Button({ children }) {
const theme = useContext(ThemeContext);
return <button className={`btn btn-${theme}`}>{children}</button>;
}
When the provided value changes, every component that reads that context re-renders.
Use context sparingly. It suits data that many components need, such as the theme, the current user, the language or a shopping cart. If only one or two levels are involved, plain props are clearer.
useReducer
When a component's state has many ways of changing, it helps to move all the update logic into one function called a reducer. The reducer takes the current state and an action that describes what happened, and returns the next state:
function tasksReducer(tasks, action) {
switch (action.type) {
case "added":
return [...tasks, { id: action.id, text: action.text, done: false }];
case "toggled":
return tasks.map(t =>
t.id === action.id ? { ...t, done: !t.done } : t
);
case "deleted":
return tasks.filter(t => t.id !== action.id);
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
Components then dispatch actions instead of setting state directly:
import { useReducer } from "react";
function TaskApp() {
const [tasks, dispatch] = useReducer(tasksReducer, []);
function handleAdd(text) {
dispatch({ type: "added", id: crypto.randomUUID(), text });
}
return (
<>
<AddTask onAdd={handleAdd} />
<ul>
{tasks.map(task => (
<li key={task.id}>
<label>
<input
type="checkbox"
checked={task.done}
onChange={() => dispatch({ type: "toggled", id: task.id })}
/>
{task.text}
</label>
<button onClick={() => dispatch({ type: "deleted", id: task.id })}>
Delete
</button>
</li>
))}
</ul>
</>
);
}
Reducers are plain functions with no React inside, so they are easy to read and test on their own.
Reducer + context
A common pattern for app-wide state is to hold state in a reducer near the top of the tree and share it through context:
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
export function TasksProvider({ children }) {
const [tasks, dispatch] = useReducer(tasksReducer, []);
return (
<TasksContext value={tasks}>
<TasksDispatchContext value={dispatch}>
{children}
</TasksDispatchContext>
</TasksContext>
);
}
export const useTasks = () => useContext(TasksContext);
export const useTasksDispatch = () => useContext(TasksDispatchContext);
Any component inside <TasksProvider> can now call useTasks() or useTasksDispatch().
Custom hooks
When two components share the same stateful logic, extract it into a custom hook. This is a function whose name starts with use and that calls other hooks.
import { useState, useEffect } from "react";
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const saved = localStorage.getItem(key);
return saved !== null ? JSON.parse(saved) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
It is used just like useState, but the value persists after a page reload:
function Settings() {
const [fontSize, setFontSize] = useLocalStorage("fontSize", 16);
// ...
}
Another example: window width
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const onResize = () => setWidth(window.innerWidth);
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return width;
}
function Layout() {
const width = useWindowWidth();
return width < 600 ? <MobileMenu /> : <DesktopMenu />;
}
Custom hooks share logic, not state. Each component that calls useWindowWidth() gets its own independent state. To share the same data between components, combine the hook with context.
What about state libraries?
For many apps, useState, useReducer and context are enough. When global state becomes large or changes often, libraries such as Zustand or Redux Toolkit offer finer control over re-renders and better debugging tools. For data that comes from a server, a data-fetching library such as TanStack Query is usually a better fit than any of these.