React6 min read
React Hooks and State Management: Practical Patterns
Practical patterns for useState, useReducer, useEffect, custom hooks, and Context, plus how to choose between local state, Redux Toolkit, and server-state libraries.
By Nitin Wagmode
Hooks are the main way React components manage state and side effects. Most state bugs come not from the hooks themselves but from putting state in the wrong place or storing more of it than necessary. This guide covers practical patterns for each core hook and a simple way to decide where state should live.
Keep state minimal
Before reaching for any hook, ask whether the value needs to be state at all. If it can be calculated from props or other state, calculate it during render:
// Avoid: duplicated state that can drift out of sync
const [items, setItems] = useState([]);
const [completedCount, setCompletedCount] = useState(0);
// Prefer: derive it
const [items, setItems] = useState([]);
const completedCount = items.filter((item) => item.done).length;Less state means fewer things to keep in sync.
useState for simple, independent values
useState is ideal for values that change independently: an input value, a toggle, or a selected tab.
When an update depends on the previous value, use the updater form so you always work with the latest state:
setCount((previous) => previous + 1);When state is an object or array, create a new one rather than changing the existing one:
setUser((previous) => ({ ...previous, name: "Asha" }));
setTodos((previous) => previous.filter((todo) => todo.id !== id));useReducer for related updates
When several values change together, or the next state depends on what action happened, useReducer keeps the logic in one predictable place:
import { useReducer } from "react";
const initialState = { status: "idle", data: null, error: null };
function reducer(state, action) {
switch (action.type) {
case "start":
return { status: "loading", data: null, error: null };
case "success":
return { status: "success", data: action.data, error: null };
case "failure":
return { status: "error", data: null, error: action.error };
default:
return state;
}
}
function useRequestState() {
return useReducer(reducer, initialState);
}With a reducer, impossible combinations, such as "loading" and "error" at the same time, cannot happen, because each action produces a complete, valid state.
useEffect: synchronizing with the outside world
useEffect is for synchronizing a component with something outside React: a network request, a subscription, a timer, or a browser API. It is often overused.
You usually do not need an effect to:
- Calculate values from props or state (derive them during render).
- Respond to a user event (do it in the event handler).
- Reset state when a prop changes (use a
keyon the component instead).
When you do use one, list every value it reads in the dependency array, and clean up anything it starts:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((response) => response.json())
.then(setUser)
.catch((error) => {
if (error.name !== "AbortError") setError(error);
});
return () => controller.abort();
}, [userId]);The cleanup cancels the previous request when userId changes, so a slow old response cannot overwrite a newer one. In development, React's Strict Mode runs effects twice on mount to help you find missing cleanups.
Custom hooks: reuse logic, not UI
When the same stateful logic appears in more than one component, move it into a custom hook. A custom hook is a function whose name starts with use and that calls other hooks:
import { useEffect, useState } from "react";
export function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage
const debouncedQuery = useDebouncedValue(query);Each component that calls a custom hook gets its own independent state. Hooks share logic, not data.
Lifting state up
When two siblings need the same data, move the state to their closest common parent and pass it down:
function FilterPage() {
const [category, setCategory] = useState("all");
return (
<>
<CategoryFilter value={category} onChange={setCategory} />
<ProductList category={category} />
</>
);
}This is often all you need, and it keeps data flow easy to follow.
Context for widely used, rarely changing values
When many components at different depths need the same value, such as the theme, the signed-in user, or language, passing props through every level becomes tedious. Context solves this:
import { createContext, useContext, useMemo, useState } from "react";
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error("useTheme must be used inside ThemeProvider");
return context;
}Every component that reads a context re-renders when its value changes, so Context works best for values that change infrequently. For frequently changing data, split contexts or use a dedicated state library.
useMemo and useCallback
These hooks cache a value or function between renders. They are useful when:
- A calculation is genuinely expensive.
- A value or function is passed to a component wrapped in
React.memo, or used in another hook's dependency array.
They are not needed for every value. Add them when profiling or a dependency issue shows a reason. Newer React tooling, such as the React Compiler, can apply this kind of memoization automatically in supported setups.
Choosing where state lives
A simple decision path:
- Used by one component? Keep it local with
useStateoruseReducer. - Shared by nearby components? Lift it to the common parent.
- Needed across the app and rarely changing? Use Context.
- Complex, frequently updated client state shared widely? Consider Redux Toolkit or a lighter library like Zustand.
- Data from a server? Consider a server-state library such as TanStack Query or RTK Query, which handle caching, refetching, and loading states for you.
Server data and client UI state are different problems. Treating API responses as cached server state, rather than copying them into global client state, often removes a lot of code.
Key takeaways
- Store the minimum state and derive everything else.
- Use
useReducerwhen updates are related or depend on actions. - Use
useEffectonly to sync with external systems, and always clean up. - Extract repeated stateful logic into custom hooks.
- Choose the narrowest scope for state: local, lifted, Context, then a library.
Conclusion
Good state management in React is mostly about placement. Keep state close to where it is used, derive what you can, and reach for global tools only when the need is real. These patterns keep components easier to understand, test, and change.
- #React
- #React Hooks
- #State Management
- #Redux Toolkit
Keep reading
- Oct 24, 2026
Getting Started with React.js
A beginner-friendly introduction to React: setting up a project with Vite, JSX, components, props, state, events, and rendering lists.5 min read - Oct 24, 2026
TypeScript Best Practices for React Applications
Practical TypeScript patterns for React: typing props and children, events, state, refs, reusable components, discriminated unions, API data, and strict compiler settings.5 min read - Oct 8, 2026
Building a JSON-Driven UI Renderer in React and TypeScript
How a typed component registry turns product configuration into UI, so new configurations ship without rebuilding screens by hand.6 min read