Must knowHooks · 02

Side Effects

useEffect for syncing with the outside world, and the cleanup that stops listeners from piling up.

On this page · 7 sections
  1. In one minute
  2. The example: the search follows the back button
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

In one minute

Rendering should only compute what to show. Anything that reaches outside React (an event listener on window, a timer, a subscription, a network request, localStorage, a non-React library) belongs in useEffect. React runs the effect after it has committed a render, and re-runs it only when something in its dependency array changed. If the effect sets something up, it should return a cleanup function that undoes it; React calls that before the next run and when the component unmounts.

You’ll be able to: write an effect with the right dependencies and a cleanup, and recognize a leaking one.

No cleanup
Search.tsx
1useEffect(() => {2  const id = nextId++3  window.addEventListener('popstate', () => {4    log(`popstate listener #${id} runs`)5    setQuery(getQueryParam())6  })7}, [])
What you see
Search: shown
popstate listeners on window: 1
Console after pressing Back (recorded)
popstate listener #1 runs
popstate listener #2 runs
popstate listener #3 runs
popstate listener #4 runs
  1. effectswindow.addEventListener('popstate', () => {On mount, the effect adds listener #1 to window.
  2. commit phaseHide the search: the component unmounts, but nothing removes its listener. It stays on window, and its closure keeps the dead component’s data alive.
  3. effectswindow.addEventListener('popstate', () => {Show it again: a fresh effect adds listener #2. Two more hide/show cycles add #3 and #4.
  4. eventlog(`popstate listener #${id} runs`)One press of Back runs every listener, three of them for components that no longer exist.

0 / 0
The search box listens for the browser’s Back button. Hide and show it three times, then press Back (both versions recorded).

The example: the search follows the back button

The search reads its first value from the URL. When the user presses Back, the URL changes, and the box should follow. The browser reports that with a popstate event, so the component subscribes to it.

Without cleanup

function SearchLeaky() {
	const [query, setQuery] = useState(getQueryParam)
	useEffect(() => {
		const id = nextId++
		window.addEventListener('popstate', () => {
			log(`popstate listener #${id} runs`)
			setQuery(getQueryParam())
		})
	}, [])
	return <input id="search" value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
}

The search can be hidden and shown with a checkbox:

function Page({ Search }: { Search: () => React.ReactNode }) {
	const [show, setShow] = useState(true)
	return (
		<>
			<label>
				<input id="show" type="checkbox" checked={show} onChange={(e) => setShow(e.currentTarget.checked)} /> Show search
			</label>
			{show ? <Search /> : null}
		</>
	)
}

The recorder hid and showed it three times, then pressed Back once:

popstate listener #1 runs
popstate listener #2 runs
popstate listener #3 runs
popstate listener #4 runs

Four handlers ran for one click: one per mount. The first three belong to components that no longer exist, and each closure keeps whatever it references alive in memory.

With cleanup

function SearchClean() {
	const [query, setQuery] = useState(getQueryParam)
	useEffect(() => {
		const id = nextId++
		function updateQuery() {
			log(`popstate listener #${id} runs`)
			setQuery(getQueryParam())
		}
		window.addEventListener('popstate', updateQuery)
		return () => window.removeEventListener('popstate', updateQuery)
	}, [])
	return <input id="search" value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
}
popstate listener #4 runs

Only the mounted component’s listener ran. The handler is a named function because removeEventListener only removes the exact same function that was added; a second arrow function with the same body is a different object.

How it works

Common mistakes

Interview Q&A

What is a side effect, and why does it need useEffect?

Anything that touches the world outside React’s rendering: listeners, timers, subscriptions, network, storage, DOM libraries. The component body runs on every render, so doing it there would repeat it every time; useEffect runs after a commit and only when the dependencies change.

What happens if an effect adds an event listener and returns no cleanup?

Each mount adds another listener and none are removed. Recorded: after three hide/show cycles, one Back press ran listeners #1–#4. They keep their closures (and everything those reference) alive: a memory leak plus duplicate work.

When does the cleanup function run?

Before the effect runs again because a dependency changed, and when the component unmounts. It always sees the values from the render that created it.

Why must the handler be the same function for addEventListener and removeEventListener?

Listeners are matched by reference. Two inline arrow functions are two different objects, so removing a new one does nothing. Name the function once inside the effect and use it in both calls.

What does an empty dependency array mean?

Run the effect after the first commit only, and run its cleanup on unmount. It’s right when the effect reads nothing that can change (here, setQuery is stable); otherwise list what it reads.

Sources

Primary source: react.dev/reference/react/useEffect