React Hooks - Common Pitfalls

Hooks are a powerful new paradigm in React that open up a world of possibilities. I believe they are one of the best things to happen to React in quite a while. Encapsulating and sharing business logic and state in a clean, reusable way? Sign me up.

Unfortunately, as with anything new and powerful, it is also easy for beginners (and even experienced devs) to misuse hooks in ways that make code more difficult to understand, debug, or performant. I'm going to do my best to cover a few of the common pitfalls I have personally experienced or seen.

useEffect - The Double-Edged Sword

useEffect is arguably the most versatile hook, but also the easiest to get wrong. It lets you perform side effects in function components, replacing lifecycle methods like componentDidMount, componentDidUpdate, and componentWillUnmount.

Pitfall 1: The Infamous Dependency Array

The second argument to useEffect is the dependency array. It tells React when to re-run your effect.

  • No array: Runs after every render. Often leads to infinite loops if the effect updates state.
  • Empty array ([]): Runs only once after the initial render (like componentDidMount). Great for setup, but won't react to prop/state changes.
  • Array with dependencies ([propA, stateB]): Runs after the initial render and whenever any value in the array changes.

The common mistake is missing dependencies. If your effect uses a prop or state variable, but it's not listed in the array, the effect will run with a stale value from a previous render when it re-runs due to other dependencies changing. This leads to subtle and frustrating bugs.

function Counter({ step }) {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // This effect uses 'step', but doesn't list it as a dependency
    const intervalId = setInterval(() => {
      console.log(`Incrementing by ${step}`); // Logs the initial 'step' value forever!
      setCount(c => c + step); // Uses the initial 'step' value forever!
    }, 1000);

    return () => clearInterval(intervalId);
  }, []); // <--- Missing 'step' dependency!

  return <div>Count: {count}</div>;
}

Solution: Always include all props and state values read inside the effect in the dependency array. Thankfully, the eslint-plugin-react-hooks package has an exhaustive-deps rule that warns you about this. Use it.

Another common issue is including unstable dependencies (like functions or objects defined inside the component body without useCallback or useMemo) which causes the effect to run more often than necessary.

Pitfall 2: Using useEffect for Derived State

Sometimes, you might be tempted to use useEffect to update state based on prop changes.

function UserProfile({ user }) {
  const [displayName, setDisplayName] = useState('');

  // DON'T DO THIS
  useEffect(() => {
    setDisplayName(user.firstName + ' ' + user.lastName);
  }, [user.firstName, user.lastName]); // Runs whenever name changes

  return <div>Display Name: {displayName}</div>;
}

This is usually unnecessary. State should represent the minimal source of truth. Derived data can often be calculated directly during rendering.

function UserProfile({ user }) {
  // Calculate directly during render
  const displayName = user.firstName + ' ' + user.lastName;

  return <div>Display Name: {displayName}</div>;
}

This is simpler, more performant, and avoids potential intermediate render states. Use useMemo if the calculation itself is expensive.

Pitfall 3: Forgetting Cleanup

If your effect sets up a subscription, timer, or event listener, you must return a cleanup function to remove it when the component unmounts or before the effect re-runs. Otherwise, you'll have memory leaks or unexpected behavior.

useEffect(() => {
  const subscription = someService.subscribe(data => { ... });

  // Cleanup function
  return () => {
    subscription.unsubscribe();
  };
}, [someService]); // Assuming someService is stable

useState and Stale Closures

useState is fundamental, but it can trip you up with asynchronous operations or event handlers defined within closures.

Pitfall: Reading Stale State

Consider an event handler that reads state and then performs an async action:

function DelayedLogger() {
  const [count, setCount] = useState(0);

  const logCountLater = () => {
    setTimeout(() => {
      // This 'count' is the value from when logCountLater was *defined*,
      // not necessarily the *current* count value!
      console.log(`Count was: ${count}`);
    }, 3000);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={logCountLater}>Log Count After 3s</button>
    </div>
  );
}

If you click Increment several times quickly and then click Log, the logged value might be lower than the displayed count because the logCountLater function closed over the count value from the render it was created in.

Solution 1: Functional Updates: Use the functional update form of the state setter if you need the previous state.

// Inside useEffect or callback where you need previous state
setCount(prevCount => prevCount + 1);

Solution 2: useRef: If you need the latest value inside a callback that shouldn't re-run often (like a useEffect with []), you can store the value in a ref.

function DelayedLogger() {
  const [count, setCount] = useState(0);
  const latestCountRef = useRef(count);

  // Keep the ref updated
  useEffect(() => {
    latestCountRef.current = count;
  }, [count]);

  const logCountLater = () => {
    setTimeout(() => {
      console.log(`Latest count is: ${latestCountRef.current}`); // Reads the latest value
    }, 3000);
  };
  // ... rest of component
}

Custom Hooks

Custom hooks are fantastic for extracting reusable logic, but remember the rules:

  • Name must start with use: This allows React and ESLint to enforce the Rules of Hooks.
  • Follow the Rules of Hooks: Only call hooks at the top level of your function component or other custom hooks. Don't call them inside loops, conditions, or nested functions.

Conclusion

Hooks offer a more direct and functional way to manage state and side effects in React. However, understanding their nuances, particularly around dependency arrays and closures, is crucial to avoid common pitfalls. Embrace the ESLint plugin, think carefully about dependencies, calculate derived state directly, and always clean up your effects. Happy hooking!