Fixing Continuous Re-Renders in React: Understanding Passing by Reference vs. Passing by Value and application of spreed operator

Introduction

Recently, while working on a React project, I encountered an issue where my component was re-rendering continuously despite implementing a debounce hook(It is just a fancy useEffect hook wraped with some additional logic). This led to unnecessary API calls and performance issues. After debugging, I realized the problem was due to how dependencies were passed in the useDebounce hook. In this post, I'll explain the issue, why it happened, and how I fixed it.


The Problem: Continuous Re-Renders in React

I had implemented a custom useDebounce hook to delay API calls when a user changed filters or/and change the name in my dashboard. Here’s what the initial debounce hook looked like:

export const useDebounce = (callback: () => void, dependencies: any[], delay: number = 500) => {
  useEffect(() => {
    let timerId = setTimeout(callback, delay);
    return () => clearTimeout(timerId);
  }, [dependencies, delay]);
};

And here’s how I used it in my component:

useDebounce(fetchUsers, [
  name,
  dependenciy1,
  dependenciy2,
  dependenciy3,
], 500);

The expectation was that fetchUsers would be debounced and only trigger after 500ms when any of the dependencies changed. However, my component kept re-rendering infinitely, causing multiple API requests.


Why Did This Happen?

The issue was with passing an array (dependencies) as a dependency in the useEffect hook. To understand the problem we have to know about Passing by Reference vs. Passing by value.

Understanding Passing by Reference vs. Passing by Value

  • Primitive values (numbers, strings, booleans) are passed by value, meaning a copy is created when they are assigned or passed as function arguments. So, if a dependency is a primitive value, React can correctly determine if it has changed.

  • Objects, arrays, and functions are passed by reference in JavaScript. This means that even if the contents of an array remain the same, creating a new array results in a new reference in memory

How This Affects useEffect

  • Since arrays are reference types, React treats {dependencies} as a new array on every render.

  • Even if the content of dependencies remains unchanged, React sees it as a new dependency.

  • This causes useEffect to run on every render, leading to infinite API calls.


The Fix: Spreading Dependencies Properly

To fix this, I have make the pass the values to pass the dependencies I spread the dependencies instead of passing them as a single array. Here’s the updated debounce hook:

export const useDebounce = (callback: () => void, dependencies: any[], delay: number = 500) => {
  useEffect(() => {
    const timerId = setTimeout(callback, delay);
    return () => clearTimeout(timerId);
  }, [...dependencies, delay]); // Spread dependencies properly
};

The spread operator (...) allows us to break an array into individual elements, ensuring React tracks each value independently rather than treating the entire array as a new reference.

Now, useEffect only runs when an actual value inside the dependencies array changes, preventing unnecessary re-renders.


Why the Solution Works

  • Ensures that useEffect runs only when individual values change, not on every render.

  • Prevents React from treating dependencies as a new reference by spreading them instead of passing an array.

  • Significantly improves performance by reducing unnecessary API calls.


Conclusion

If you ever face a situation where a debounce hook or/and a useEffect hook is causing infinite re-renders, check if you’re passing an array as a dependency in useEffect. Instead, spread the dependencies correctly to ensure React only re-runs the effect when necessary. This simple fix can help optimize performance and avoid unnecessary API calls!