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 lets a function component synchronise with something outside React, such as a subscription or browser event. Some uses overlap with class lifecycle methods, but it is not a one‑to‑one replacement for them:
- Setup runs after a render is committed, not during the render itself.
- A dependency change makes React run the previous cleanup before setting up the synchronisation again.
- Cleanup also runs when the component unmounts; it should undo the work performed by that setup.
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, useEffect has no dependency array, so React runs its setup after every committed render. The setup still belongs to the effect phase; it is not executed as ordinary render‑time code.
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.
Return a cleanup function from the useEffect setup. React calls it before a later setup runs and when the component unmounts. That applies to effects without a dependency array too, as well as those whose listed dependencies 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 => {
useEffect(() => {
const resizeListener = (): void => console.warn('window resized');
// 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.
- An empty dependency array is correct when the setup reads no reactive values. It runs after mounting, runs again on remount and may have an extra setup‑cleanup cycle in development Strict Mode.
- List every reactive value read by the setup. React compares each with its previous value using
Object.is, then repeats the synchronisation when needed. - With no dependency array, React runs setup after every committed render, cleaning up the previous setup first. It still does not run during rendering.
Treat the dependency list as a description of what the effect reads. Keeping that list complete and pairing setup with cleanup makes the component easier to reason about. The list controls when synchronisation repeats; it does not directly prevent the component from rendering.