08 · useEffect Basics¶
Rendering must be pure: take props and state, return JSX, touch nothing else. But apps
need to do things outside React — set the page title, start a timer, subscribe to a
WebSocket, save to localStorage. useEffect is where that code goes. Think of an
effect as synchronizing some external system with your current props and state.
Shape of an effect¶
import { useEffect, useState } from 'react'
function PageTitle() {
const [unread, setUnread] = useState(3)
useEffect(() => {
document.title = unread > 0 ? `(${unread}) Inbox` : 'Inbox'
}, [unread])
return <button onClick={() => setUnread(0)}>Mark all read</button>
}
- The first argument is the setup function. React runs it after the render has been committed to the screen.
- The second is the dependency array: the reactive values the effect reads. React
re-runs the effect only when one of them changed since last time (compared with
Object.is).
| Dependencies | Effect runs |
|---|---|
[a, b] |
after mount, and after any render where a or b changed |
[] |
after mount only (and again on remount) |
| omitted | after every render |
Cleanup¶
If setup starts something, return a function that stops it:
function Clock() {
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(id)
}, [])
return <time>{now.toLocaleTimeString()}</time>
}
React calls cleanup before re-running the effect with new dependencies, and when the component unmounts. Without it, every remount would leave another interval running.
In development, StrictMode deliberately mounts, unmounts and remounts each component
once. If you see your effect run twice, that's the test: with correct cleanup, the
visible behaviour is identical. If it breaks, the bug would have hit real users later
(for example when navigating away and back).
Dependencies are not optional¶
Every prop, state variable, or value computed from them that the effect reads must be in
the array. The react-hooks/exhaustive-deps lint rule (enabled in the Vite template)
checks this for you. Don't silence it; fix the cause.
function Countdown({ from }) {
const [left, setLeft] = useState(from)
useEffect(() => {
if (left <= 0) return
const id = setTimeout(() => setLeft(left - 1), 1000)
return () => clearTimeout(id)
}, [left])
return <p>{left > 0 ? `${left}s` : 'Done!'}</p>
}
Worked example: persist to localStorage¶
import { useEffect, useState } from 'react'
export default function Notepad() {
const [text, setText] = useState(() => localStorage.getItem('notepad') ?? '')
const [savedAt, setSavedAt] = useState(null)
useEffect(() => {
const id = setTimeout(() => {
localStorage.setItem('notepad', text)
setSavedAt(new Date())
}, 500) // debounce: save 500ms after typing stops
return () => clearTimeout(id)
}, [text])
return (
<div>
<textarea value={text} onChange={e => setText(e.target.value)} rows={8} cols={50} />
<p>{savedAt ? `Saved at ${savedAt.toLocaleTimeString()}` : 'Not saved yet'}</p>
</div>
)
}
Each keystroke changes text, so React cleans up the pending timeout and sets a new
one. Only when typing pauses for 500 ms does a save actually happen. The cleanup is what
turns a naive "save on every change" into a debounce.
Worked example: subscribe to a browser event¶
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth)
useEffect(() => {
function onResize() {
setWidth(window.innerWidth)
}
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
return <p>Window is {width}px wide</p>
}
The function passed to removeEventListener must be the same function that was
added, which is why it's defined inside the effect.
You might not need an effect¶
Effects are for synchronizing with things outside React. Many beginner effects should just be calculations:
// ❌ extra state + effect + an extra render with stale data
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(`${first} ${last}`)
}, [first, last])
// ✅ derive during render
const fullName = `${first} ${last}`
Likewise, code that responds to a user action ("when the form is submitted, send a request") belongs in the event handler, not in an effect watching some flag. Ask: is this caused by the component being on screen, or by a specific event? Only the former is an effect.
Data fetching with effects is covered properly in Level 2, including race conditions.
How It Actually Works¶
During rendering, useEffect doesn't run anything. It records the setup function and
dependency array in the component's hook slot (by call order, like useState) and marks
the fiber as having an effect to process.
After React commits the DOM changes, it runs effects in a later pass — usually after the
browser has painted, so effects don't delay the first visual frame. For each effect it
compares the new dependency array with the one stored from the last commit, item by
item with Object.is. If any item differs (or there's no array), it runs the previous
cleanup (if any) and then the new setup, storing the returned cleanup for next time.
The key insight is that each render has its own effect. The setup function is a closure created during a particular render, so it sees that render's props and state. If you leave a value out of the dependency array, React keeps the old effect — along with the old closure — which then keeps reading old values. That's a stale closure:
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000) // captures count = 0 forever
return () => clearInterval(id)
}, []) // ❌ count missing
The interval callback always sees the count from the first render, so the counter
goes to 1 and stays there. The fixes: add count to deps (the interval is recreated
each second), or better, remove the dependency by using the updater form
setCount(c => c + 1) which doesn't read count from the closure.
useLayoutEffect has the same API but runs synchronously after DOM mutation and
before the browser paints. It exists for measuring layout (e.g. positioning a
tooltip) without a visible flicker; it blocks painting, so use useEffect by default.
Common mistakes¶
- Missing dependencies → stale values. Trust the linter.
- Objects or functions created during render as dependencies — they're new every render, so the effect runs every time. Move them inside the effect or compute from primitives.
- Setting state unconditionally in an effect that depends on that state → infinite loop.
- Forgetting cleanup for intervals, listeners and subscriptions → leaks and duplicate handlers.
- Using an effect to derive data or to react to a button click. Compute during render; handle clicks in handlers.
asyncsetup function.useEffect(async () => ...)returns a promise, not a cleanup. Define an async function inside and call it.
Exercise¶
Build a FocusTimer (Pomodoro-style):
- State: remaining seconds (start at 25 × 60),
running(boolean), and completed sessions count. - An effect that ticks once per second only while
runningis true, with proper cleanup. Use the updater form so the interval doesn't needremainingas a dependency. - When remaining hits 0, stop, increment sessions and reset to 25:00 — do this in the
updater/tick logic, not in a second effect that watches
remaining. - Keep
document.titlein sync with the remaining time (e.g.12:04 · Focus). - Persist the completed-sessions count to
localStorageand restore it on load. - Verify in dev with
StrictModethat the timer doesn't run at double speed.