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

In Brief
An empty dependency array tells React that an effect has no reactive dependencies, so it does not rerun because a dependency changed. It will run again if the component is remounted, and React's development Strict Mode may perform an extra setup‑and‑cleanup cycle to expose unsafe effects. Include every reactive value the effect reads rather than emptying the array merely to suppress reruns.
This is something that came up recently whilst pair‑programming 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 vanillauseEffect, which triggers at the point of initial render.componentDidUpdate: which ran as after updates, is replaced byuseEffectwith passed dependencies. When any of those dependencies change, theuseEffectruns.componentWillUnmount: which triggered at the point that the component unmounted, is now replaced by using cleanup functions within youruseEffecthooks 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 above, omitting the second argument asks React to run the setup after every committed render. A dependency list is not primarily a performance switch: it declares every reactive value read by the setup so React knows when that synchronisation must be repeated.
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 ([]), there are no reactive values to compare, so the setup runs after mounting and not after later renders of that mount. The empty list is correct only when the setup genuinely reads no reactive value. Here's an example:
import React, { useEffect } from 'react';const MyComponent = (): JSX.Element => { useEffect(() => { console.log('This effect runs after this component mounts'); }, []); return <div>Hello, World!</div>;};In a production mount, the setup runs after the component mounts and will not repeat during that mount. It runs again after a later remount. In React 18 development Strict Mode, React deliberately performs an extra setup‑cleanup‑setup cycle to expose missing cleanup; that check does not occur in production.
A Technical Explanation
React does not make this decision by comparing the dependency‑array object itself. It compares each declared dependency with its previous value using Object.is. An empty array has no values to compare, regardless of the fact that a new array literal is created during each render.
With no declared dependencies, React skips repeating the setup for later renders of the same mount. This resembles part of componentDidMount, but it is not a direct lifecycle replacement: the setup may return cleanup, development Strict Mode may exercise that pair twice, and every reactive value read by the setup still belongs in the list.
- 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?
When the setup reads props, state, or variables and functions declared inside the component, those reactive values belong in the dependency list. React repeats the setup after a render in which at least one listed value differs by Object.is, running the previous cleanup first.
As an example, here's a component where the useEffect will re‑run when the particular prop read by the setup 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 re‑run.
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 according to Object.is. The list describes the reactive values used by the setup; it should not omit a value merely to suppress a repeat.
A Brief Aside...
Although these examples use props, dependencies can also be state values and variables or functions declared directly inside the component. Values defined outside the component are not reactive merely because the setup can read them.
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, React runs the setup after every committed render of the component.
That does not turn the setup into a normal render‑time function: React still runs it after the commit, and runs the preceding cleanup before the next setup. Omitting the list explicitly selects that after‑every‑render schedule.
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 re‑renders frequently. This approach could lead to unnecessary computations, repeated API calls, or other expensive operations.
The dependency list makes the synchronisation contract explicit. If repeating work is too expensive, change the setup or stabilise the reactive value rather than supplying an incomplete list.
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 re‑run (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 thewindowor 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 re‑runs based on the values you care about. The effect will re‑run every time one of those dependencies changes.
- If you don't pass a dependency array at all, then the
useEffectessentially reverts back to an inline function and will trigger every time the component renders.
As a front‑end 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 re‑renders and memory leaks and ensuring proper resource cleanup once they are done with.