Skip to main content

useEffect

In this chapter, you learn about the useEffect hook, which handles side effects.

What you'll learn in this chapter

  • What a side effect is and why you need useEffect
  • Controlling the execution timing with the dependency array
  • How to use the cleanup function
  • Common mistakes and how to handle them
info

This chapter uses the project created in Chapter 3. Start the development server with npm run dev and study while writing code.

What is a side effect

A side effect is processing that occurs outside of a component's rendering.

  • Fetching data from an API
  • Directly manipulating the DOM
  • Setting timers
  • Registering event listeners
  • Accessing local storage
info

React recommends "pure rendering." Pure rendering means always returning the same result for the same props and state. useEffect is the mechanism for safely running processing (side effects) that happens outside this "purity."

The basics of useEffect

import { useEffect, useState } from 'react'

function Example() {
const [count, setCount] = useState(0)

const handleIncrement = () => {
setCount(count + 1)
}

// useEffect: describe a side effect that runs after rendering
// 1st argument: the function to run
// 2nd argument: the dependency array (if omitted, it runs every time)
useEffect(() => {
// Update the browser tab title (an example of a side effect)
document.title = `Count: ${count}`
})

return (
<div>
<p>{count}</p>
<button onClick={handleIncrement}>+1</button>
</div>
)
}

The dependency array

You specify a dependency array as useEffect's second argument.

No dependency array

It runs after every render.

useEffect(() => {
console.log('Runs every time')
})

An empty dependency array

It runs once on mount.

useEffect(() => {
console.log('Runs only on mount')
}, []) // empty array → runs once when the component mounts
warning

In StrictMode (see Chapter 3), useEffect runs twice during development. This is not a bug; it's to check whether the cleanup processing is implemented correctly. In production, it runs only once.

Specifying values to depend on

It runs when the specified value changes.

useEffect(() => {
console.log(`count changed: ${count}`)
}, [count]) // runs only when count changes

Let's try it: update the document title

Create a counter component and reflect the count value in the browser tab title.

Hint
  1. Manage the count value with useState
  2. Update document.title inside useEffect
  3. Specify count in the dependency array
Answer and explanation
import { useState, useEffect } from 'react'
import './App.css'

function DocumentTitle() {
const [count, setCount] = useState(0)

const handleIncrement = () => {
setCount(prev => prev + 1)
}

useEffect(() => {
document.title = `Count: ${count}`
}, [count])

return (
<div>
<p>Count: {count}</p>
<button onClick={handleIncrement}>+1</button>
</div>
)
}

export default function App() {
return <DocumentTitle />
}

By specifying count in the dependency array, document.title is updated only when count changes. If you check the browser tab, you can see the count value reflected in the title.

The cleanup function

If you return a function from useEffect, it runs as cleanup processing.

useEffect(() => {
const handleResize = () => {
console.log('Resize')
}

// Register an event listener
window.addEventListener('resize', handleResize)

// Cleanup function: return a function
// It's called when the component unmounts, or
// before the effect re-runs because a dependency array value changed
return () => {
// Remove the registered event listener (prevents a memory leak)
window.removeEventListener('resize', handleResize)
}
}, [])

Practical examples

Timer

function Timer() {
const [seconds, setSeconds] = useState(0)

useEffect(() => {
// setInterval: repeatedly runs a function every specified number of milliseconds
// The return value is the timer ID (used when stopping)
const interval = setInterval(() => {
setSeconds(prev => prev + 1)
}, 1000) // 1000 milliseconds = 1 second

// Cleanup: stop the timer
return () => clearInterval(interval)
}, []) // empty array → starts once on mount

return <p>Elapsed time: {seconds}s</p>
}

Let's try it: a stopwatch

Create a stopwatch that you can control with start/stop buttons, and implement the cleanup processing correctly too.

Hint
  1. Control start/stop with an isRunning State
  2. Specify isRunning in useEffect's dependency array
  3. Call clearInterval in the cleanup
  4. Adding a reset button is convenient
Answer and explanation
import { useState, useEffect, useRef } from 'react'
import './App.css'

function Stopwatch() {
const [seconds, setSeconds] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const intervalRef = useRef<number | null>(null)

useEffect(() => {
if (isRunning) {
intervalRef.current = window.setInterval(() => {
setSeconds(prev => prev + 1)
}, 1000)
}

return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [isRunning])

const handleStartStop = () => {
setIsRunning(prev => !prev)
}

const handleReset = () => {
setIsRunning(false)
setSeconds(0)
}

return (
<div>
<p>{seconds}s</p>
<button onClick={handleStartStop}>
{isRunning ? 'Stop' : 'Start'}
</button>
<button onClick={handleReset}>Reset</button>
</div>
)
}

export default function App() {
return <Stopwatch />
}

We start/stop setInterval in response to changes in isRunning. By always calling clearInterval in the cleanup function, the timer is properly released when the component unmounts or the effect re-runs.

Syncing with local storage

function useLocalStorage(key: string, initialValue: string) {
const [value, setValue] = useState(() => {
const saved = localStorage.getItem(key)
return saved ?? initialValue
})

useEffect(() => {
localStorage.setItem(key, value)
}, [key, value])

return [value, setValue] as const
}

function App() {
const [name, setName] = useLocalStorage('name', '')

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

return (
<input
value={name}
onChange={handleChange}
placeholder="Enter a name"
/>
)
}

Watching the window size

function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
})

useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
})
}

window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])

return size
}

function App() {
const { width, height } = useWindowSize()

return <p>Screen size: {width} x {height}</p>
}

Let's try it: display the window size

Create a component that displays the current window size.

Hint
  1. Manage width/height with useState
  2. Watch for resizing with window.addEventListener('resize', handler)
  3. Call removeEventListener in the cleanup
  4. Register once on mount with an empty dependency array []
Answer and explanation
import { useState, useEffect } from 'react'
import './App.css'

function WindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
})

useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
})
}

window.addEventListener('resize', handleResize)

return () => {
window.removeEventListener('resize', handleResize)
}
}, [])

return (
<div>
<p>Width: {size.width}px</p>
<p>Height: {size.height}px</p>
</div>
)
}

export default function App() {
return <WindowSize />
}

By specifying an empty dependency array [], we register the event listener once on mount. By removing the listener in the cleanup function, we prevent a memory leak. When you resize the browser window, the display updates.

The execution timing of useEffect

1. Rendering (computing the DOM)
2. DOM update
3. useEffect runs
info

useEffect runs asynchronously after rendering. Since it doesn't block rendering, it's less likely to affect performance.

Common mistakes

Infinite loops

If you pass an object or array created during render to the dependency array, it becomes a new reference every time, so the effect re-runs on every render. Furthermore, if you update State inside the effect, the re-render → re-run loop never stops (an infinite loop).

// NG: infinite loop
useEffect(() => {
fetchData(options)
}, [options]) // options is a new object every time

// OK: depend only on the necessary properties
useEffect(() => {
fetchData({ page, limit })
}, [page, limit])

Missing dependencies

Enable ESLint's exhaustive-deps rule to prevent missing dependencies.

// NG: count is not in the dependency array
useEffect(() => {
console.log(count)
}, [])

// OK
useEffect(() => {
console.log(count)
}, [count])

When you only want to read the latest value from an effect: useEffectEvent (React 19.2 and later)

There are cases where you want to "read the latest props or state inside an effect, but you don't want the effect to re-run when that value changes." For example, in an effect that connects to a chat room, you want to reconnect when roomId changes, but you don't want to reconnect just because theme, used to display notifications, changed.

If you put theme in the dependency array, it reconnects every time you switch themes. But if you remove theme from the dependency array, ESLint's exhaustive-deps rule warns you, and you end up referencing the stale theme from the time of connection.

In such cases, use useEffectEvent, which became stable in React 19.2. A function wrapped with useEffectEvent (an effect event) can always read the latest props or state at the time it's called.

import { useEffect, useEffectEvent } from 'react'

// createConnection (chat connection) and showNotification (display notification)
// are assumed to be defined in another file
function ChatRoom({ roomId, theme }: { roomId: string; theme: string }) {
// Effect event: the theme referenced inside is always the latest value
const onConnected = useEffectEvent(() => {
showNotification('Connected', theme)
})

useEffect(() => {
const connection = createConnection(roomId)
connection.on('connected', () => {
onConnected()
})
connection.connect()
return () => connection.disconnect()
}, [roomId]) // no need to put theme in the dependency array → changing theme doesn't reconnect

return <p>Room: {roomId}</p>
}

useEffectEvent has two constraints.

  • It can only be called from inside an effect: You cannot call it inside an event handler or during render
  • Don't put it in the dependency array: An effect event is treated as "non-reactive," so exclude it from the dependency array

Notes on data fetching

Data fetching with useEffect is a basic pattern, but for production applications we recommend using a library such as TanStack Query. *TanStack Query is covered in detail in Chapter 20.

// A basic pattern (for learning)
type User = { id: number; name: string }

function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)

useEffect(() => {
// The ignore flag: whether this effect run has become stale (already cleaned up)
// It becomes true both on unmount and before re-running because userId changed
let ignore = false

async function fetchUser() {
try {
setLoading(true)
const response = await fetch(`/api/users/${userId}`)
const data = await response.json()
// Don't update state in a stale run (prevents overwriting with old data)
if (!ignore) {
setUser(data)
}
} catch (e) {
if (!ignore) {
setError(e as Error)
}
} finally {
if (!ignore) {
setLoading(false)
}
}
}

fetchUser()

// Cleanup: set the flag to prevent state updates
// Without this, old data may be displayed when switching users
return () => {
ignore = true
}
}, [userId]) // re-fetch the data each time userId changes

if (loading) return <p>Loading...</p>
if (error) return <p>Error: {error.message}</p>
if (!user) return <p>User not found</p>

return <p>{user.name}</p>
}

Canceling a request with AbortController

The ignore flag above only suppresses state updates; the actual communication runs to completion. When you want to save bandwidth or CPU, it's preferable to abort the request itself with AbortController.

useEffect(() => {
const controller = new AbortController()

async function fetchUser() {
try {
setLoading(true)
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
})
const data = await response.json()
setUser(data)
} catch (e) {
// AbortError is an expected error on cancellation, so ignore it
if ((e as Error).name === 'AbortError') return
setError(e as Error)
} finally {
// An aborted run also doesn't touch state here (so it doesn't clear the new request's "loading" display)
if (!controller.signal.aborted) {
setLoading(false)
}
}
}

fetchUser()

return () => {
controller.abort() // abort the actual HTTP request
}
}, [userId])
info

Using AbortController, you can truly cancel an old request when userId switches rapidly. The ignore flag approach is simple and easy to understand, so it's suitable for learning, but in practice using AbortController is the norm. Many data fetching libraries (such as TanStack Query) also leverage AbortController internally.

Options for not using useEffect

The React official documentation also recommends "keeping useEffect to the minimum necessary." The following cases can be solved with more appropriate means rather than useEffect.

Compute derived values during render

Managing "a value that can be computed from other state" with useEffect + useState is redundant. Computing it on every render is enough. If the computation is expensive, you can optimize it with the React Compiler's automatic memoization or manual memoization with useMemo (see Chapter 22, Performance).

// ❌ Sync a derived value with useEffect (unnecessary)
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])

// ✅ Compute during render
const fullName = `${firstName} ${lastName}`

Write event-driven processing in the handler

Processing that "reacts to a user action," like a button click or form submission, is naturally written in the event handler. useEffect is for processing that runs as a result of rendering, and is not suited to being event-driven.

// ❌ Detect submission completion with useEffect
useEffect(() => {
if (isSubmitted) sendAnalytics('form_submitted')
}, [isSubmitted])

// ✅ Call it directly in the submit handler
async function handleSubmit() {
await submitForm()
sendAnalytics('form_submitted')
}

For external stores, use useSyncExternalStore

When subscribing to an external store like Zustand or Redux, or a browser API (such as window.matchMedia), using useSyncExternalStore is the correct answer rather than hand-rolling it with useState + useEffect. Consistency with concurrent rendering is handled automatically (covered in detail in Chapter 15, Custom hooks).

For the result of a Promise, use use(Promise) (React 19+)

When receiving a Promise from a parent component or Server Components, you can read it directly with the use hook rather than resolving it with useEffect. You can delegate loading management to a Suspense boundary and keep it concise.

import { use } from 'react'

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise) // Suspense handles the loading
return <p>{user.name}</p>
}
info

However, use(Promise) has neither caching nor re-fetching. If you're managing API data in an SPA, Chapter 20's TanStack Query (useSuspenseQuery) is more suitable.

Summary

  • useEffect is a hook for handling side effects
  • Control the execution timing with the dependency array
  • Release resources with the cleanup function
  • Watch out for missing dependencies (use the ESLint rule)
  • Consider using a dedicated library for data fetching

In the next chapter, you learn about useRef and DOM manipulation.