Skip to content
NW
All articles

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

Connected nodes representing state flowing between React components

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:

jsx
// 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:

jsx
setCount((previous) => previous + 1);

When state is an object or array, create a new one rather than changing the existing one:

jsx
setUser((previous) => ({ ...previous, name: "Asha" }));
setTodos((previous) => previous.filter((todo) => todo.id !== id));

When several values change together, or the next state depends on what action happened, useReducer keeps the logic in one predictable place:

jsx
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 key on the component instead).

When you do use one, list every value it reads in the dependency array, and clean up anything it starts:

jsx
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:

jsx
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:

jsx
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:

jsx
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:

  1. Used by one component? Keep it local with useState or useReducer.
  2. Shared by nearby components? Lift it to the common parent.
  3. Needed across the app and rarely changing? Use Context.
  4. Complex, frequently updated client state shared widely? Consider Redux Toolkit or a lighter library like Zustand.
  5. 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 useReducer when updates are related or depend on actions.
  • Use useEffect only 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