Why We Use an Empty Dependency Array in React's useEffect Hook

Hero image for Why We Use an Empty Dependency Array in React's useEffect Hook. Image by Neil Kami.
Hero image for 'Why We Use an Empty Dependency Array in React's useEffect Hook.' Image by Neil Kami.

This is something that came up recently whilst pairprogramming with one of our new junior developers. When you're working with React's useEffect hook, you will almost certainly have used an empty array ([]) as the second argument, possibly without giving it any more thought than 'this is how we do it'.

Nevertheless, this small and innocuous array is critical to determining how and when your effect runs. Here, I intend to dive into how React's useEffect hook behaves, what that dependency array (or lack thereof) actually does, and how different configurations of the dependency array can impact your components and their lifecycles.


The Basics of useEffect

Starting at the beginning, and for those who are relatively new to React or unfamiliar, it is a powerful hook (introduced as part of React 16.8) that allows us to perform side effects within functional components. Side effects can include things like fetching data, setting up subscriptions or listeners, or otherwise directly interacting with the DOM.

Before hooks were introduced into React, the only way to really manage a component's lifecycle was by using lifecycle methods within class components. You would often see methods like componentDidMount, componentDidUpdate, componentWillUnmount, etc., to trigger functions at specific points in the lifecycle.

useEffect essentially replaces these lifecycle methods in functional components, shifting development focus from reacting to timing within a component's lifecycle to reacting to specific events within the component, props, and data instead.

  • componentDidMount: which used to trigger as the component mounted, is replaced with a vanilla useEffect , which triggers at the point of initial render.
  • componentDidUpdate: which ran as after updates, is replaced by useEffect with passed dependencies. When any of those dependencies change, the useEffect runs.
  • componentWillUnmount: which triggered at the point that the component unmounted, is now replaced by using cleanup functions within your useEffect hooks to handle unmounting.

Here's a very simple example of a useEffect in action:

import React, { useEffect } from 'react';const MyComponent = (): JSX.Element => {  useEffect(() => {    console.log('Component rendered or updated!');  });  return <div>Hello, World!</div>;};

Here, the useEffect has no dependency array at all, which means that React will call the function passed to it every time the component renders. Whilst this might be fine and useful for some scenarios, it is rarely efficient to run an effect hook on every render.


Why We Use an Empty Array

As you'll have seen in the example above, by default, an effect will run after every render unless you provide a second argument. As you might appreciate, this can be very costly in terms of performance, and this is where including a dependency array comes into play.

In effect, what you're doing when you pass an array of dependencies to a hook is saying, "Please run the code within this hook every time one of these dependencies changes". However, if you pass an empty array ([]), it will only run at the point that the component mounts for the first time. Here's an example:

import React, { useEffect } from 'react';const MyComponent = (): JSX.Element => {  useEffect(() => {    console.log('This effect will only run on the first render');  }, []);  return <div>Hello, World!</div>;};

In this case, the effect will only run once when the component first renders. After that, React won't trigger the effect again unless the component is unmounted and remounted again.

A Technical Explanation

Whilst the dependency array is empty, it still exists as a set of dependencies. In JavaScript, arrays are compared by reference rather than value (which I've written about before), so when React compares an empty array ([]), what it sees is a reference which doesn't change between each component render; the reference remains constant.

As a result, React concludes that none of the dependencies have changed and thus skips rerunning the effect as the component state changes and/or it rerenders. This makes it a direct replacement for componentDidMount, and is particularly useful when you have side effects that should only occur once, such as:

  • Fetching preliminary data for the component as it first mounts;
  • Setting up a WebSocket connection;
  • Subscribing to a stream of events.

What Happens with Other Dependencies?

As you might have gathered, instead of passing an empty array, you can pass specific dependencies into the array instead. Then, React will only rerun the effect when one of those dependencies changes.

As an example, here's a component where the useEffect will rerun every time a prop passed to the component changes:

import React, { useEffect } from 'react';const MyComponent = ({ count }: { count: number }): JSX.Element => {  useEffect(() => {    console.log('Effect triggered because count changed:', count);  }, [count]);  return <div>Count: {count}</div>;};

Here, useEffect depends on the count prop, so it is passed into the dependency array. The effect will then run whenever the value of count changes. If count doesn't change between renders, then the effect won't rerun.


Multiple Dependencies

Given that it's a dependency array, you might have already guessed that we can pass more than one dependency into a useEffect. The result is that every time either (or any) of the dependencies change, the effect will run. For example:

import React, { useEffect } from 'react';const MyComponent = ({  count,  name,}: {  count: number;  name: string;}): JSX.Element => {  useEffect(() => {    console.log(      'Effect triggered because either count or name  has changed:',      count,      name    );  }, [count, name]);  return (    <div>      <p>Count: {count}</p>      <p>Name: {name}</p>    </div>  );};

In this case, the effect will run whenever either count or name changes. Essentially, you supply the effect with multiple values that need to be watched in order to control the effect's behaviour.

A Brief Aside...

As a brief aside: although I'm using component props in my examples here, these could just as easily be state items or even just common or garden variables within the component code.


What Happens Without a Dependency Array?

As I touched upon back at the start of this article, if you don't pass a dependency array to useEffect at all (i.e., you omit the second argument), then the contents of the effect will run after every single component render or state change.

Since React doesn't have any specific dependencies to watch for changes, it essentially reverts to a normal function, assuming that the effect should be executed every time the component updates, no matter what triggered the rerender.

Here's that first example I shared with you again to illustrate the point:

import React, { useEffect } from 'react';const MyComponent = (): JSX.Element => {  useEffect(() => {    console.log('Component rendered or updated!');  });  return <div>Hello, World!</div>;};

Although this may seem relatively harmless in simple components, it can very easily snowball into significant performance issues, especially if your effect does any heavy lifting or the component rerenders frequently. This approach could lead to unnecessary computations, repeated API calls, or other expensive operations.

The dependency array offers you finite control over when the effect should be rerun, improving performance and making your component behaviour more predictable.


Cleaning Up a useEffect

When your effect involves side effects such as setting up listeners or subscriptions, opening WebSocket connections or starting timers, these are resources that need to be cleaned up as the component unmounts again or the dependencies change. Otherwise, you run the risk of memory leaks or unwanted behaviour, as they may continue to run even after the component is unmounted.

React allows you to handle this simply by using the return function within your useEffect. This is triggered whenever the component unmounts or when the effect is about to rerun (if there are dependencies and they change).

The example I tend to use when describing this is tying resize events to the window. You don't want that to carry on triggering after the component that uses it has been unmounted again, so you use removeEventListener in the return to cancel it again:

import React, { useEffect } from 'react';const MyComponent = (): JSX.Element => {  const resizeListener = (): void => console.warn('window resized');  useEffect(() => {    // set up an event listener for resize on window when the component first mounts    window.addEventListener('resize', resizeListener);    // in the return, remove it again    return () => {      window.removeEventListener('resize', resizeListener);    };  }, []);  return <div>Hello, World!</div>;};

The cleanup function is particularly important for tasks like:

  • Unsubscribing from streams or WebSocket connections

    : Leaving connections open after the component is unmounted can result in unnecessary network traffic and security vulnerabilities.
  • Clearing timers:

    If you're using intervals or timeouts, not clearing them can cause unexpected behaviour if the component is removed as they carry on triggering in memory.
  • Removing event listeners:

    As I've shown in the code example above, event listeners attached to the window or other DOM elements may continue to fire after the component is unmounted if you don't clean them up.

Making sure that you clean up your effects properly ensures that your components don't leave behind unwanted processes, helping improve performance and stability or behaviour.


Wrapping up

This article moved quite a long way away from answering the original question: Why We Use an Empty Dependency Array in React's useEffect Hook?

To wrap things up, the dependency array in React's useEffect hook plays a crucial role in determining just how and when your effect should run.

  • Using an empty array ensures that the effect only runs once when the component first mounts.
  • By specifying dependencies in the array, you can control when the effect reruns based on the values you care about. The effect will rerun every time one of those dependencies changes.
  • If you don't pass a dependency array at all, then the useEffect essentially reverts back to an inline function and will trigger every time the component renders.

As a frontend developer (and especially a junior one), understanding how to use the dependency array effectively will help you write more efficient and predictable React components, reducing unnecessary rerenders and memory leaks and ensuring proper resource cleanup once they are done with.


Categories:

  1. Front‑End Development
  2. JavaScript
  3. React