DEV Community

Cover image for Solved: Common useEffect anti-patterns I see in code reviews (and how to fix them)
Darian Vance
Darian Vance

Posted on • Originally published at wp.me

Solved: Common useEffect anti-patterns I see in code reviews (and how to fix them)

🚀 Executive Summary

TL;DR: Infinite loops in React’s useEffect often stem from referential equality issues with non-primitive dependencies defined within components. Solutions range from hoisting static values and memoizing dynamic ones with useMemo/useCallback to abstracting data fetching entirely using custom hooks or libraries like TanStack Query.

🎯 Key Takeaways

  • JavaScript’s referential equality for non-primitive types (objects, arrays, functions) is the root cause of many useEffect infinite loops.
  • Defining objects or functions inside a component and including them in useEffect’s dependency array will cause the effect to re-run on every render due to new references being created.
  • Solutions include hoisting static dependencies outside the component, using useMemo for objects/arrays and useCallback for functions to stabilize references, or abstracting data fetching logic with custom hooks or libraries like TanStack Query for a more robust, declarative approach.

Struggling with infinite loops and re-renders in React? We’ll break down a common useEffect anti-pattern I see constantly in code reviews and show you how to fix it for good, from a quick patch to a permanent architectural solution.

From My Code Review Desk: The useEffect Anti-Pattern That’s Crashing Your App

I still remember the PagerDuty alert that woke me up at 3:17 AM on a Tuesday. The alert was for our primary user-auth service, and the metrics were terrifying: CPU on our prod-auth-api-01 node was pinned at 100%, and network traffic was through the roof. It looked like a classic DDoS attack. After 20 minutes of frantic digging, we found the culprit. It wasn’t an external attacker. It was us. A junior engineer had pushed a seemingly harmless UI change, which included a useEffect hook that was caught in an infinite loop, relentlessly hammering our own /api/v1/users/me endpoint thousands of times per second from every active user’s browser. That’s the day I started taking useEffect code reviews very seriously.

The “Why”: Understanding Referential Equality

Before we jump into the fixes, let’s get to the root of the problem. It’s not really useEffect‘s fault; it’s about how JavaScript handles objects and functions. In JavaScript, non-primitive types (Objects, Arrays, Functions) are compared by reference, not by value. This means {} === {} is always false, because they are two separate objects in memory, even if they look identical.

React’s useEffect hook runs its effect after every render *if* something in its dependency array has changed. When you put an object or function that’s defined inside your component body into that dependency array, you’re setting a trap. On every single render, a new function or object is created. React compares the “new” object to the “old” one, sees they are different references, and dutifully re-runs your effect. If that effect updates state, it triggers another render, which creates another new object, and… you get the picture. Hello, 3 AM wake-up call.

The Code That Triggers the PagerDuty Alert

Here’s a simplified version of the code that caused our outage. We have a component that needs to fetch user data based on a set of options.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  // This object is re-created on EVERY render
  const fetchOptions = {
    method: 'GET',
    headers: { 'Content-Type': 'application/json' },
  };

  useEffect(() => {
    console.log('Fetching user data...');
    fetch(`/api/users/${userId}`, fetchOptions)
      .then(res => res.json())
      .then(data => setUser(data));

  // The linter correctly warns us that `fetchOptions` is a dependency.
  // But adding it causes an infinite loop!
  }, [userId, fetchOptions]); // <-- The trap is set

  if (!user) return <p>Loading...</p>;

  return <h2>Welcome, {user.name}</h2>;
}
Enter fullscreen mode Exit fullscreen mode

The developer saw the linting rule “React Hook useEffect has a missing dependency: ‘fetchOptions’” and dutifully added it to the array. Boom. Infinite loop. The fetch causes a state update, which causes a re-render, which creates a new fetchOptions object, which causes the effect to run again.

Solution 1: The “Quick & Dirty” Fix (Hoisting)

Sometimes you just need to stop the bleeding. The fastest way to fix this specific issue is to move the dependency outside of the component, so it’s only created once when the module is loaded, not on every render.

The Code:

// Define it ONCE, outside the component's render cycle.
const FETCH_OPTIONS = {
  method: 'GET',
  headers: { 'Content-Type': 'application/json' },
};

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`, FETCH_OPTIONS)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]); // Now `fetchOptions` is stable and not needed here

  // ... render logic
}
Enter fullscreen mode Exit fullscreen mode

Why It Works (and When It Doesn’t):

This works because FETCH_OPTIONS is now a constant with a stable reference. It’s never re-created. This is a great fix if the object is truly static and doesn’t depend on any props or state. If your object needs to include a dynamic value (like a token from state), this approach falls apart.

Solution 2: The “Proper Engineer” Fix (useMemo & useCallback)

The “right” way to solve this within the component is to tell React, “Hey, don’t re-create this thing unless its own dependencies have changed.” We use the useMemo hook for objects/arrays and useCallback for functions.

The Code:

import { useState, useEffect, useMemo, useCallback } from 'react';

function UserProfile({ userId, authToken }) {
  const [user, setUser] = useState(null);

  // useMemo ensures this object reference is stable
  // unless `authToken` itself changes.
  const fetchOptions = useMemo(() => ({
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${authToken}`,
    },
  }), [authToken]);

  // If you were defining a fetch function instead, you'd use useCallback
  const fetchUser = useCallback(() => {
     return fetch(`/api/users/${userId}`, fetchOptions)
      .then(res => res.json());
  }, [userId, fetchOptions]);


  useEffect(() => {
    fetchUser().then(data => setUser(data));
  }, [fetchUser]); // The dependency is now a stable, memoized function

  // ... render logic
}
Enter fullscreen mode Exit fullscreen mode

Why It Works:

useMemo and useCallback “memoize” the value. They give you back the exact same object or function reference on every re-render, *unless* one of the dependencies you pass to *them* (e.g., [authToken]) has changed. This breaks the infinite loop while still allowing the effect to re-run when things it truly depends on (like the auth token) actually change.

A Word of Caution: Don’t go wrapping everything in useMemo. It has a small performance cost. Use it when you are passing non-primitive values to dependency arrays or as props to memoized child components. Don’t use it for simple calculations.

Solution 3: The “Nuclear Option” (Abstract the Logic)

After you’ve fixed this bug a few times, you start to realize the problem is architectural. Data fetching logic is a cross-cutting concern; it doesn’t really belong inside a UI component. The best long-term solution is to move it out completely.

The Fix: A Custom Hook or a Library

You can create your own custom hook to encapsulate the fetching logic, state, and effects. Or, even better for production apps, use a battle-tested library like TanStack Query (formerly React Query) or SWR.

The Code (with TanStack Query):

import { useQuery } from '@tanstack/react-query';

const fetchUser = async (userId) => {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) {
    throw new Error('Network response was not ok');
  }
  return res.json();
};

function UserProfile({ userId }) {
  // All the useEffect, useState, error handling, and loading logic
  // is now handled by this one hook.
  const { data: user, isLoading, isError } = useQuery({
    queryKey: ['user', userId], // A unique key for this data
    queryFn: () => fetchUser(userId),
  });

  if (isLoading) return <p>Loading...</p>;
  if (isError) return <p>Error loading user.</p>;

  return <h2>Welcome, {user.name}</h2>;
}
Enter fullscreen mode Exit fullscreen mode

Why It’s the Best Option:

This approach completely sidesteps the problem. The library authors have already solved all the tricky dependency and re-fetching issues for you. You just tell it *what* to fetch and *when* to consider it stale. It handles caching, background refetching, and eliminates the need for manual useEffect management for server state. This makes your components cleaner, more declarative, and much less prone to bugs like the one that cost me a night’s sleep.

So next time you’re in a code review and see a complex useEffect, take a moment to think about the dependency array. You might just save your team from a 3 AM fire drill.


Darian Vance

👉 Read the original article on TechResolve.blog


☕ Support my work

If this article helped you, you can buy me a coffee:

👉 https://buymeacoffee.com/darianvance

Top comments (0)