DEV Community

Cover image for The Simple JavaScript Detail That Cost Me Months in React
Bishoy Semsem
Bishoy Semsem

Posted on • Originally published at bishoy-bishai.github.io

The Simple JavaScript Detail That Cost Me Months in React

The Subtle JavaScript Trap That Cost Me Months in React

Alright, let's talk shop. If you've spent any significant time building applications with React, you know it's a powerful tool. But like any powerful tool, it has its sharp edges. For me, one particular edge – a seemingly simple JavaScript detail – repeatedly sliced through my productivity, leading to months of frustrating debugging and performance headaches. It wasn't some complex Redux saga or a weird custom hook; it was something far more fundamental, something that most beginner tutorials gloss over: the true nature of closures and reference equality in JavaScript, especially when they intersect with React's useEffect hook.

I've found that this is a silent killer in many projects, especially as they scale. You write a component, things work. Then a month later, a subtle bug appears: a piece of state doesn't update, an API call uses stale data, or an effect fires unnecessarily. You stare at the code, convinced it's correct. "The state is updating!" you think. "Why isn't my effect seeing it?"

Here's the thing: React doesn't magically understand the contents of your JavaScript objects or functions by default. It primarily cares about their references. And if you're not acutely aware of when those references change (or, more crucially, don't change), you're setting yourself up for a world of pain.

The Story: A Flaky Feature and the Ghost of Stale Data

Let me tell you about a particular incident. We were working on a dashboard feature where users could filter a list of items and then perform bulk actions. The filtering logic was fairly complex, living in a custom hook that debounced user input and fetched data. The bulk action component used useEffect to fetch options for the actions themselves, based on the currently selected items.

// Simplified version of the problematic setup
function useFilteredItems(query: string) {
  const [items, setItems] = useState<Item[]>([]);
  // ... debounce logic, API fetch based on query ...
  return items;
}

function BulkActions({ selectedItemIds }: { selectedItemIds: string[] }) {
  const [actionOptions, setActionOptions] = useState<ActionOption[]>([]);

  // The culprit: This effect *should* update action options when selectedItemIds changes
  useEffect(() => {
    console.log('Fetching action options for IDs:', selectedItemIds);
    // Imagine an async API call here using selectedItemIds
    const fetchedOptions: ActionOption[] = fetchActionOptions(selectedItemIds);
    setActionOptions(fetchedOptions);
  }, [selectedItemIds]); // Dependency array: selectedItemIds

  // ... rest of the component
}

function MyDashboard() {
  const [searchQuery, setSearchQuery] = useState('');
  const allItems = useFilteredItems(searchQuery);
  const [selectedIds, setSelectedIds] = useState<string[]>([]);

  // ... render filtering UI, item list, selection checkboxes ...

  return (
    <div>
      {/* ... */}
      <BulkActions selectedItemIds={selectedIds} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The bug? When you'd select items for the first time, everything worked fine. The BulkActions component would correctly fetch and display the action options. But then, if you changed your selection – deselected a few, added a couple more – sometimes, the actionOptions would not update! The useEffect should have re-run because selectedItemIds changed, right?

We checked the selectedItemIds prop in the BulkActions component itself – it was definitely receiving the new array. Yet, the effect often wouldn't re-run. Hours turned into days, then weeks, as this bug intermittently resurfaced. We'd log selectedItemIds inside useEffect, and it would correctly show the old value from the previous render if the effect didn't fire, and the new value if it did. The inconsistency was maddening.

The Deep Dive: Reference Equality and Closures

The problem, as it almost always is in these situations, boiled down to reference equality.

In JavaScript, objects (and arrays are objects!) are compared by reference, not by value.

const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
const arr3 = arr1;

console.log(arr1 === arr2); // false (different references in memory)
console.log(arr1 === arr3); // true (same reference in memory)
Enter fullscreen mode Exit fullscreen mode

React's useEffect (and useCallback, useMemo) uses this strict === comparison for its dependency array. If you pass an array or object directly into the dependency array, and a new array/object is created on every render, then React will see a "change" in reference and re-run the effect.

In our dashboard example, the selectedIds state in MyDashboard was being updated. When setSelectedIds was called with a new array (e.g., setSelectedIds([...prevSelectedIds, newId])), then BulkActions would receive a new selectedItemIds prop by reference, and the effect would re-run.

The problem arose when, for some reason, the array passed to setSelectedIds was, by chance, the same reference as the previous one, even if its contents had changed in a mutation, or if it was an empty array that got re-created but still happened to be [] === [] (which is false, but sometimes the logic creating the array could return the same reference unintentionally). More commonly, the issue was with objects or arrays created inline during a render.

Consider this variant:

function MyComponent({ data }: { data: { id: string; value: number }[] }) {
  // Problem: data.filter(...) creates a *new array reference* on every render,
  // even if the filtered results are the same.
  const expensiveComputationResult = useMemo(() => {
    return data.filter(item => item.value > 10)
               .map(item => item.id);
  }, [data.filter(item => item.value > 10).map(item => item.id)]); // BAD dependency!
  // This dependency array expression creates a new array on every render!
  // So useMemo will always re-run its callback.

  // Correct way: depend on `data`, and let `useMemo` handle its own internal computations.
  const expensiveComputationResultCorrect = useMemo(() => {
    return data.filter(item => item.value > 10)
               .map(item => item.id);
  }, [data]); // GOOD dependency! Only re-runs if `data` (the prop reference) changes.

  // ...
}
Enter fullscreen mode Exit fullscreen mode

The original bug turned out to be a slightly convoluted chain of events where the selectedIds array, under specific user interactions, was sometimes being mutated rather than replaced, or a function that created the selectedIds array was returning the same reference under certain conditions where it should have returned a new one.

Insights: What Most Tutorials Miss

  1. Reference is King: This is the absolute core. React's diffing algorithm and hook dependency arrays operate on reference equality. Understand this deeply. It's not about the values inside an object or array; it's about whether the object/array itself is a new instance in memory.

  2. Inline Object/Array Creation: Every time you write {} or [] directly within your render function or as a prop, you're creating a new reference on every single render. This is critical for useEffect, useCallback, and useMemo. If you pass an inline object or array into a dependency array, that effect/memoized value will re-run/re-calculate on every render, negating the purpose of the hooks!

    // BAD: `{ type: 'foo' }` creates a new object every time
    useEffect(() => { /* ... */ }, [{ type: 'foo' }]);
    
    // GOOD: A primitive value or a stable reference
    const config = useMemo(() => ({ type: 'foo' }), []); // Stable config object
    useEffect(() => { /* ... */ }, [config]);
    
  3. Functions are Objects Too: Remember that functions in JavaScript are also objects. If you define a function directly within a component's render body, its reference will change on every render. This is why useCallback exists: to give you a stable function reference across renders.

    // BAD: `handleClick` function reference changes every render
    function MyButton({ onClick }: { onClick: () => void }) {
        useEffect(() => {
            console.log('Button click handler changed');
        }, [onClick]); // This effect will run on every render because onClick is new every time
        return <button onClick={onClick}>Click Me</button>;
    }
    
    function Parent() {
        const [count, setCount] = useState(0);
        const handleClick = () => setCount(c => c + 1); // New function reference on every Parent render
        return <MyButton onClick={handleClick} />;
    }
    
    // GOOD: `useCallback` memoizes the function
    function ParentCorrect() {
        const [count, setCount] = useState(0);
        const handleClick = useCallback(() => setCount(c => c + 1), []); // Stable function reference
        return <MyButton onClick={handleClick} />;
    }
    

Pitfalls to Avoid (and How to Recover)

  1. Over-optimization with useCallback/useMemo: Don't wrap everything in useCallback or useMemo. These hooks have their own overhead. Only use them when you have a performance problem, or when you need a stable reference for a dependency array of another hook (useEffect, useLayoutEffect, etc.) or when passing props to a React.memoized child component.

  2. Missing Dependencies: Forgetting to include a dependency in useEffect (or useCallback/useMemo) leads to stale closures. Your effect will "close over" the values from the render it was defined in, and never see the updated values. This is often caught by ESLint's react-hooks/exhaustive-deps rule, which you must enable and heed.

  3. Circular Dependencies / Complex useCallback Chains: Sometimes, you'll find yourself in a situation where A depends on B, and B depends on A, leading to complex dependency arrays for useCallbacks. This is often a sign that your state structure or component boundaries might need a rethink. Can you lift state up? Can you split a component? Can you use useReducer to centralize complex state logic and actions?

    For example, if a handleSubmit function needs formState and also needs to call an onSuccess callback which depends on formState from the parent, it can get tricky. Often, passing dispatch from useReducer down is a cleaner approach, as dispatch is guaranteed to be stable.

  4. Mutating State Directly: Never, ever directly mutate state objects or arrays in React. Always create a new copy.

    // BAD: Directly mutating an array
    const handleClick = () => {
      myArray.push('new item');
      setMyArray(myArray); // React sees the *same reference*, won't re-render
    };
    
    // GOOD: Creating a new array
    const handleClick = () => {
      setMyArray(prevArray => [...prevArray, 'new item']); // New array reference
    };
    

Wrap-up: The Takeaway

The simple JavaScript detail that cost me months wasn't a hidden React feature; it was a fundamental misunderstanding (or rather, an under-appreciation) of how JavaScript's closure and reference equality rules play out in the dynamic, re-rendering world of React.

My biggest lesson learned: Approach useEffect (and useCallback/useMemo) dependency arrays with extreme prejudice. When you see an object or a function in a dependency array, immediately ask: "Will this reference be stable across renders?" If the answer is "no" and it shouldn't re-run, you need to either memoize it (useCallback, useMemo) or rethink your state structure. If the answer is "yes, it's stable" but its internal values might change, then you need to ensure React is getting a new reference when those internal values conceptually change.

Mastering this distinction will transform your React debugging experience from a frustrating hunt for ghosts into a predictable, logical process. It's not about being a JavaScript guru; it's about being a thoughtful React developer. Happy coding!


> 🚀 Read on My Blog

Top comments (0)