Skip to content
React Hooks Best Practices: Avoiding Common Pitfalls in Production Apps
Web Development11 min read

React Hooks Best Practices: Avoiding Common Pitfalls in Production Apps

Scult Team
11 min read

Hooks didn't remove complexity from React — they moved it into a smaller number of sharper edges. Here's where production apps actually break, and how to fix the root cause instead of the symptom.

The bug report reads the same way almost every time: a button that used to work fine now submits stale data, or a component re-renders in a loop the moment a state setter fires, or a data fetch that resolves out of order and overwrites fresh data with old data. Nine times out of ten, the root cause is a Hook used correctly in the tutorial sense but incorrectly in the production sense — a dependency array that's technically complete but semantically wrong, or a closure that captured a value from three renders ago. Hooks didn't remove complexity from React function components; they moved it into a smaller number of sharper edges, and most teams cut themselves on the same two or three edges repeatedly.

As a React development company that spends a fair amount of time reviewing and fixing existing codebases rather than starting from a blank slate, we see the same handful of patterns cause the majority of Hook-related production bugs. None of them require exotic knowledge to avoid — they require understanding what a Hook is actually doing under the hood, not just what the documentation example shows.

The Stale Closure Problem

Every render of a function component creates a new closure. Every function defined inside that render — including every callback passed to useEffect, setTimeout, or an event handler — closes over the values that existed at that specific render, not the current value in some shared mutable box. This is the single most common source of confusion for developers moving from class components, where this.state always pointed at current state.

The classic failure case: a setInterval set up inside useEffect with an empty dependency array, referencing a piece of state in its callback. The interval runs forever with the value of that state frozen at whatever it was during the first render, because the closure was created once and never rebuilt. The fix isn't always "add the dependency" — sometimes it's using a functional state update (setCount(c => c + 1) instead of setCount(count + 1)) so the callback never needs to read the outer variable at all, or storing the current value in a ref that the interval reads from instead.

Understanding that closures are the mechanism, not just "sometimes state feels stale," changes how you debug this category of bug. Instead of guessing at dependency arrays until the warning goes away, you can reason about exactly which render created the function currently running.

Dependency Arrays Are Not Optional Suggestions

The ESLint react-hooks/exhaustive-deps rule gets disabled more often than any other rule in codebases we inherit, almost always with a // eslint-disable-next-line and no comment explaining why. In the majority of cases, the rule was right, and disabling it just deferred a bug rather than avoiding one.

A dependency array's job is to tell React "re-run this effect when any of these values change." Omitting a value that the effect actually reads doesn't make the effect run less often in a good way — it makes the effect use a stale value from an earlier render while pretending the current one didn't change. The two legitimate reasons to see this rule fight you are:

  • You genuinely want the effect to run once, in which case the values it reads should be ones that are stable across the component's lifetime (a ref, a value from useState's initializer, or something memoized correctly) — not values that change but that you're choosing to ignore.
  • You're triggering an action, not syncing state — for instance, an effect that should fire once when a component mounts to log an analytics event. Even here, it's worth asking whether an event handler at the actual point of user action would be a better fit than an effect at all, since useEffect is designed for synchronizing with external systems, not for running arbitrary logic on a schedule.

The practical rule we apply in code review: if you're disabling exhaustive-deps, the PR needs a comment explaining the specific reason, and that reason needs to be one of the two above — not "the linter was annoying."

useEffect Cleanup and Race Conditions

Async operations inside useEffect are a frequent source of race-condition bugs that only show up under real network latency, which is exactly why they slip through local development and QA on fast connections. The pattern: a component fetches data based on a prop (say, a user ID), the prop changes before the first fetch resolves, a second fetch starts, and the first fetch's response arrives after the second one and overwrites it with stale data.

The fix is a cleanup function that sets a flag the async callback checks before applying its result:

useEffect(() => {
  let cancelled = false;
  fetchUser(userId).then(data => {
    if (!cancelled) setUser(data);
  });
  return () => { cancelled = true; };
}, [userId]);

This pattern — or the equivalent using AbortController for actual request cancellation — should be the default for any data fetch inside an effect, not an optimization added after the race condition is reported in production. On projects that use React Query, SWR, or similar data-fetching libraries, this problem is handled for you, which is one of the strongest arguments for using one rather than hand-rolling useEffect fetches on every component that needs server data.

Overusing useMemo and useCallback

The opposite failure mode is real too: wrapping every function in useCallback and every computed value in useMemo on the theory that memoization is always a performance win. It isn't. useMemo and useCallback have their own cost — comparing the dependency array on every render, holding the previous value in memory — and for a cheap computation or a function passed to a DOM element rather than a memoized child component, that cost usually exceeds the cost of just recomputing the value.

Memoization earns its keep in two situations: preventing a genuinely expensive computation from re-running when its inputs haven't changed, and preventing a function or object identity from changing on every render when it's passed as a prop to a component wrapped in React.memo. Outside of those two cases, reflexively memoizing everything adds cognitive overhead — more dependency arrays to get wrong — for no measurable benefit. We profile before we memoize, using the React DevTools Profiler to find components that actually re-render expensively, rather than treating memoization as a default coding style.

Custom Hooks: The Right Way to Share Logic

Custom Hooks are the correct answer to "this logic is duplicated across five components," but they're often built as thin wrappers around a single useEffect without thinking about the contract they expose. A well-built custom hook should hide its internal implementation detail — whether it uses useState, useReducer, or a ref internally — behind a small, stable return value that consuming components can rely on without knowing how it's built.

Two habits improve custom hooks noticeably:

  • Return a consistent shape, ideally an object with named properties rather than a positional array, once a hook returns more than two or three values. useMyThing() returning { data, error, isLoading, refetch } is far easier to consume and extend than a five-element array where order matters.
  • Keep side effects contained. If a custom hook subscribes to something — a WebSocket, a browser event, a timer — it needs to clean up that subscription in every code path, including early returns and error states. A leaked subscription from a custom hook used in fifty places across an app is fifty times harder to trace than one used in a single component.

useRef Beyond DOM References

useRef gets introduced as "the way to grab a DOM node," which is true but undersells what it actually is: a mutable box that persists across renders without triggering a re-render when it changes. That property makes it the right tool for values a component needs to remember but never needs to display — a previous prop value for comparison, an interval ID to clear later, a flag for whether a component is still mounted.

The mistake we see most often is using a ref for something that actually belongs in state — a value that should trigger a UI update when it changes, silently stored in a ref instead, leaving the UI out of sync until some unrelated re-render happens to catch it up. The dividing line is simple: if the value should ever cause a re-render when it changes, it's state; if it's bookkeeping the render doesn't need to reflect, it's a ref.

Testing Hooks Without Testing Implementation Details

Hooks that manage genuinely complex logic — pagination, form validation, multi-step wizards — deserve tests, but the useful tests exercise behavior through the component's actual rendered output or through React Testing Library's hook-testing utilities, not through reaching into internal state. A test that asserts on the value a useReducer call returns internally will break the moment you refactor from useReducer to plain useState, even though nothing about the hook's external behavior changed. Testing what a hook does — the values it exposes, the side effects it produces — rather than how it's implemented internally keeps the test suite useful through refactors instead of becoming a maintenance burden that gets deleted the first time it's inconvenient.

State Batching and Why Updates Don't Always Merge the Way You Expect

React batches multiple state updates that happen within the same event handler into a single re-render, which is usually invisible and helpful — three setState calls in one click handler produce one render, not three. The pitfall shows up when developers assume this guarantees the values they read afterward reflect the update immediately. Reading a state variable on the line right after calling its setter still returns the pre-update value, because the update is scheduled, not applied synchronously. Code that reads count immediately after setCount(count + 1) and expects the new value is relying on a timing assumption that never held, even before React 18 extended batching to cover promises, timeouts, and native event handlers as well.

The practical fix is the same one that solves several other problems on this list: use the functional updater form, setCount(prev => prev + 1), whenever a new state value depends on the previous one. It sidesteps both the stale-closure problem and the batching-timing confusion in one move, since the updater function always receives the most current pending value rather than whatever the enclosing closure captured.

useEffect vs Event Handler: Picking the Right Tool

A recurring architectural mistake is reaching for useEffect to respond to something that was actually a direct consequence of a user action. If a component needs to show a toast notification after a form submits successfully, the natural instinct is often to set a submitted flag in state and add an effect that watches for it to trigger the toast. This works, but it's solving a problem that a plain function call inside the submit handler already solves more directly, without an extra render cycle, an extra piece of state, and an extra dependency array to maintain.

The dividing line worth internalizing: useEffect exists to synchronize a component with an external system — the DOM, a subscription, browser storage, a server connection — not to sequence application logic that's already triggered by a specific event. Any time an effect's dependency array is really just tracking "did this user action happen," it's worth asking whether the logic belongs directly inside the handler for that action instead. Beyond being simpler, this avoids an entire category of bugs where an effect fires again on a re-render for a reason unrelated to the original user action, triggering the same side effect (a duplicate API call, a duplicate toast) a second time.

A Practical Checklist for Hook-Heavy Codebases

When we take on a Custom Software Development or Web Development project that inherits an existing React codebase, a fast Hooks audit covers:

  • Every eslint-disable on react-hooks/exhaustive-deps has a comment justifying it, or gets fixed.
  • Every async operation inside useEffect has a cancellation or cleanup path.
  • useMemo/useCallback usage is backed by an actual profiling reason, not applied reflexively everywhere.
  • Custom hooks return a stable, named shape and clean up every subscription on every exit path.
  • State and refs aren't crossed — nothing that should trigger a re-render is hiding in a ref.

None of these fixes are glamorous, and none of them show up in a demo. What they prevent is the slow accumulation of "it works most of the time" bugs that make a React codebase feel increasingly fragile as it grows — the kind of thing that's cheap to fix at the component level and expensive to untangle once five more features have been built on top of the same stale closure.

Want results like this?

Keep reading