React

I Broke React's #1 Rule: My 2025 Conditional Hook Verdict

Ever faced a mysterious infinite loop that crashed your browser? I thought I broke React, but the culprit was a simple misunderstanding of the useEffect hook.

A

Alex Grayson

Senior Frontend Engineer specializing in React, performance optimization, and debugging tricky bugs.

7 min read19 views

I Broke React

The screen flickered. The cooling fan on my laptop, usually a silent servant, whirred to life with the desperation of a jet engine on takeoff. My browser tab, once a pristine canvas for my latest creation, now displayed a single, ominous message: "This page has become unresponsive."

My first thought, a cold knot of dread in my stomach, was simple and absurd: I broke React.

Of course, I hadn't actually broken the library used by millions. There was no vulnerability I'd magically discovered, no catastrophic flaw in the reconciler. The truth was far more common, and frankly, more humbling. I had stumbled headfirst into a classic trap, a misunderstanding of a fundamental concept that sent my application into a tailspin. This is the story of how a seemingly innocent useEffect hook brought my app to its knees, and the crucial lesson it taught me about how React truly thinks.

The Scene of the Crime

The feature seemed straightforward enough: a component to display a user's profile data, fetched from an API. To make it a bit more dynamic, I wanted to include some client-side options, like how the user's list of activities should be sorted. Simple, right?

My initial code looked something like this. See if you can spot the problem.

import React, { useState, useEffect } from 'react';

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

  // Define some fetching options
  const fetchOptions = {
    includeDetails: true,
    sort: 'descending'
  };

  useEffect(() => {
    console.log('Fetching data...');
    setIsLoading(true);
    fetchUserData(userId, fetchOptions)
      .then(data => {
        setUser(data);
      })
      .finally(() => {
        setIsLoading(false);
      });
  }, [userId, fetchOptions]); // The dependency array. Our prime suspect.

  if (isLoading) {
    return <div>Loading profile...</div>;
  }

  return <div>{/* ... JSX to render user data ... */}</div>;
}

On the surface, it seems logical. The useEffect hook is designed to run side effects, like data fetching. The dependency array, [userId, fetchOptions], tells React, "Please re-run this effect if and only if the userId or the fetchOptions change." So, if we navigate to a new user's profile, userId changes, and we refetch. Perfect.

Except that's not what happened. Instead, my console flooded with an endless stream of "Fetching data..." logs, the component re-rendered maniacally, and the browser gave up. I had created an infinite loop.

The Investigation: Why Did It Break?

My initial reaction was denial. "But the options object never changes!" I insisted to my rubber duck. It's always { includeDetails: true, sort: 'descending' }. So why does React think it's different on every single render?

The answer lies in a core JavaScript concept that every React developer must internalize: referential equality.

Dependency Arrays and the Curse of Referential Equality

Advertisement

When React checks the dependency array to see if it should re-run an effect, it doesn't do a "deep" comparison of the values. It doesn't look inside an object and say, "Ah, the `sort` property is the same, so we're good."

Instead, it does a strict equality check (===). For primitive types like strings, numbers, and booleans, this works as you'd expect. 'descending' === 'descending' is true.

But for non-primitive types—objects and arrays—JavaScript compares them by reference. It checks if they are the exact same object in memory, not if their contents are identical.

Let's trace the execution of my broken component:

  1. Initial Render: The UserProfile component runs. A new object, fetchOptions, is created in memory. The useEffect runs for the first time.
  2. State Update: The fetch completes, and setUser(data) is called. This triggers a re-render.
  3. Re-render: The UserProfile component function runs again. A brand new fetchOptions object is created in memory. It has the same key-value pairs, but it's a completely different object at a different memory address.
  4. Dependency Check: React compares the dependencies from the previous render to the current ones. It compares the old fetchOptions object with the new one. Since they are different objects in memory, the check oldOptions === newOptions returns false.
  5. Effect Re-run: React sees that a dependency has "changed." It runs the effect again.
  6. Infinite Loop: The fetch is triggered again, which will eventually call setUser, which triggers another re-render, which creates another new fetchOptions object... and the cycle continues forever.

I hadn't broken React. I'd simply handed it a new object on every render and told it to run an effect whenever that object changed. It was doing exactly what I asked it to do.

The Fixes: How to Un-Break React

Once you understand the problem of referential equality, the solutions become clear. The goal is to provide a stable reference to the dependency array so that React doesn't see a "new" object on every render. Here are a few ways to achieve that.

Solution 1: Memoize the Object with `useMemo`

The most direct fix for this specific problem is to use the useMemo hook. It lets you memoize (a fancy word for remember or cache) a value between renders. It will only re-compute the value when one of its own dependencies changes.

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

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

  // The object is now memoized. It will only be recreated if
  // one of the dependencies in *its* dependency array changes.
  // Since the array is empty, it's created once and reused.
  const fetchOptions = useMemo(() => ({
    includeDetails: true,
    sort: 'descending'
  }), []); // Empty array = compute only once

  useEffect(() => {
    console.log('Fetching data... (just once!)');
    fetchUserData(userId, fetchOptions);
  }, [userId, fetchOptions]); // Now fetchOptions has a stable reference

  // ...
}

Now, the fetchOptions object is created on the first render and its reference is stored by React. On subsequent re-renders, React sees that the dependencies for useMemo haven't changed, so it returns the *exact same* object reference. The useEffect dependency check passes, and the loop is broken.

Solution 2: Deconstruct and Use Primitives

Sometimes, you don't even need an object. If the values are static or can be defined as primitives, you can often simplify the code by moving the object creation *inside* the effect and listing the primitive values in the dependency array.

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

  const includeDetails = true;
  const sortOrder = 'descending';

  useEffect(() => {
    // The object is now an implementation detail of the effect
    const fetchOptions = {
      includeDetails: includeDetails,
      sort: sortOrder
    };
    console.log('Fetching data...');
    fetchUserData(userId, fetchOptions);
  }, [userId, includeDetails, sortOrder]); // All primitives, all stable!

  // ...
}

This is often the cleanest solution. Since primitive values are compared by value, React can easily tell that 'descending' has not changed between renders.

A Quick Word on `useCallback`

This same exact problem applies to functions defined inside your component. If you pass a function as a dependency to useEffect (or as a prop to a memoized child component), you must wrap it in useCallback. It's the same principle as useMemo, but specifically for functions. It memoizes the function reference itself.

The Broader Lesson: Thinking in React

This entire ordeal taught me a lesson that goes far beyond a single hook. It forced me to refine my mental model of how React works. A component function is not a one-time setup; it's a blueprint that can be re-run at any time. Every variable and function you define inside it is temporary, recreated from scratch on every render, unless you explicitly tell React to preserve it with a hook like useState, useMemo, or useCallback.

This is why the React team provides the eslint-plugin-react-hooks package. Its `exhaustive-deps` rule is not just a suggestion; it's a powerful guardrail. It would have immediately flagged my missing dependencies or warned me about the unstable object reference, forcing me to confront the problem before it crashed my browser.

So, did I break React? Not at all. I just had to learn to speak its language more fluently. The language of renders, state, and, most importantly, stable references.

Every "broken" app is a fantastic learning opportunity. The infinite loop I accidentally created taught me more about React's core principles than a dozen tutorials ever could. So the next time your laptop fan starts spinning and your page becomes unresponsive, don't panic. Take a deep breath, check your dependency arrays, and get ready for a breakthrough. You might be closer to mastering React than you think.

You May Also Like