Lesson 3 · Hooks in depth
Effects (useEffect)
Syncing with the outside world, cleanup, race conditions and when you DON’T need an effect.
Loading lesson…
Lesson 3 · Hooks in depth
Syncing with the outside world, cleanup, race conditions and when you DON’T need an effect.
Loading lesson…
From the previous lessons you know that a component is a cook who makes a dish (JSX) from a recipe (props and state). It has one strict rule: while cooking, it only cooks. It doesn't call suppliers, turn on the radio or text the guests. Why? Because React can have it cook whenever and as many times as it likes – sometimes it cooks a dish and throws it away again. If the cook called a supplier every time they cooked, the supplier would get dozens of pointless orders.
But a real app has to talk to the outside world: start a timer, connect to a chat, change the tab title, listen to the window size. For that React has effects – tasks that run after the cooking, when the dish is on the table (the page is already rendered).
The best way to understand an effect is not as "do something once" but as keeping things in sync. Imagine walking around your flat and wanting the light on in whichever room you're in:
That's exactly how React runs effects: it switches on when the component appears; switches off and on again when whatever the effect depends on changes; and switches off for the last time when the component disappears. You just describe how to switch on and how to switch off.
We want the browser tab's title to show the number of unread messages: "(3) React Course". But the title doesn't belong to React – it lives in document.title, outside JSX. So it's "the outside world" that we need to keep in sync with.
function Inbox() {const [unread, setUnread] = useState(0)// ❌ Not like this: writing to the outside world right while cooking// document.title = `(${unread}) React Course`// ✅ An effect: runs only after renderinguseEffect(() => {document.title = `(${unread}) React Course`}, [unread])return <button onClick={() => setUnread((u) => u + 1)}>A message arrived</button>}
What happens after a click:
setUnread orders a new render. React calls Inbox() – the cook makes new JSX. Nobody has touched the title yet.[unread]: has the value changed since last time? Yes (0 → 1), so it runs the effect and the title is rewritten.So the function passed to useEffect is "what to do after rendering". The array at the end is the list of values the effect depends on – we'll look at it in detail in section 3.
What you see: A counter of unread messages. The effect writes it into the title of this browser tab and restores the title in its cleanup.
Try it:
The takeaway: The state is in React, the title outside it. The effect keeps them in sync: after every change of unread it updates the title, and when leaving it cleans up after itself.
Loading the interactive part…
Some effects start something that keeps running on its own: a timer, a connection to a server, a window event listener. That's like switching on a light – it won't switch itself off. Whoever doesn't switch off behind them pays a big electricity bill a month later. In an app this is called a memory leak: timers keep running even though their component disappeared long ago.
A typical example: a clock that updates every second.
function Clock() {const [now, setNow] = useState(() => new Date())useEffect(() => {// Switch on: start an intervalconst id = setInterval(() => setNow(new Date()), 1000)// Switch off: a function React calls during cleanupreturn () => clearInterval(id)}, [])return <p>{now.toLocaleTimeString()}</p>}
[] means "the effect depends on nothing". It runs once when the clock appears, and the cleanup happens when it disappears.setNow on a component that no longer exists. And every new mount would add another interval.The whole life of such an effect:
| What happens | What React does |
|---|---|
| The clock appears on the page | Runs the effect → the interval runs. |
| The clock re-renders every second | Nothing – the dependencies [] didn’t change, the effect stays. |
| The clock disappears from the page | Calls the cleanup → the interval is cancelled. |
What you see: The clock from the example above. Below it is a log, into which the effect writes "▶ starting the interval" and the cleanup "■ cancelling the interval".
Try it:
The takeaway: Every "starting" has its own "cancelling". Thanks to that, two intervals never run at once, however many times the clock is mounted and unmounted.
Loading the interactive part…
Back to the light. Dependencies are the answer to the question "which room am I in?" As long as you're in the same room, you don't switch anything. When the room changes, you switch off in the old one and switch on in the new one.
A typical example: connecting to a chat room the user picks.
function ChatRoom({ roomId }: { roomId: string }) {useEffect(() => {const connection = connect(roomId) // switch on in room roomIdreturn () => connection.disconnect() // switch off in it}, [roomId]) // the room I'm inreturn <h3>Welcome to #{roomId}</h3>}
The user switches from #general to #react:
roomId = "react"."general", now "react" – a change.#general.#react.| Syntax | When the effect runs |
|---|---|
useEffect(fn, [a, b]) | After the first render and every time a or b changes. The most common. |
useEffect(fn, []) | Only after the first render (and the cleanup on unmount). |
useEffect(fn) | After every render. You rarely want that. |
The array takes everything from the component that the effect reads: props, state and values calculated from them. It's not a list of "when I want the effect to run", but "what the effect works with". What happens if you leave something out:
function Counter() {const [count, setCount] = useState(0)useEffect(() => {const id = setInterval(() => setCount(count + 1), 1000)return () => clearInterval(id)}, []) // ❌ the effect reads count, but it's not in the dependencies}
The effect runs once, on the first render, when count is 0. The interval carries that zero with it (it's the snapshot from the lesson on state) and every second sets 0 + 1. The counter freezes at 1.
The fix isn't "add count to the dependencies" (the interval would then be cancelled and recreated every second), but to change the code so it doesn't need count:
useEffect(() => {const id = setInterval(() => setCount((c) => c + 1), 1000) // ✅ an updaterreturn () => clearInterval(id)}, []) // now the effect really reads nothing from the component
In the evening you order a pizza. A minute later you change your mind and order sushi. The sushi arrives in twenty minutes, the pizza in forty. If you're not careful, you eat the sushi, then the pizza arrives and you eat that too – even though you no longer wanted it. It's wiser to tell yourself: "I cancelled the pizza – if it turns up, I won't take it".
It's the same with loading data in an effect. The user clicks on Anna, then right away on Peter. Two requests are sent and the responses can arrive in any order. When Anna arrives after Peter, she overwrites him – and the screen shows a different person from the one selected.
function Profile({ userId }: { userId: number }) {const [user, setUser] = useState<User | null>(null)useEffect(() => {let ignore = false // "I still want this order"fetchUser(userId).then((data) => {if (ignore) return // the order was cancelled → don't take itsetUser(data)})return () => {ignore = true // cleanup = cancelling the order}}, [userId])}
Let's go through a quick switch from Anna (id 1) to Peter (id 2):
| Time | What happens | ignore |
|---|---|---|
| 0 ms | The effect for Anna: sends request 1. | Anna: false |
| 100 ms | A click on Peter → the cleanup of Anna’s effect. | Anna: true |
| 100 ms | The effect for Peter: sends request 2. | Peter: false |
| 600 ms | Peter arrives → ignore is false → he’s shown. | Peter: false |
| 1200 ms | Anna arrives → ignore is true → she’s thrown away. | Anna: true |
Every run of the effect has its own ignore variable. The cleanup switches only its own, so the old response recognises that nobody wants it any more.
What you see: Buttons with names and a profile card that loads after a click. The fake API answers with a random delay of 0.3–1.5 s. The checkbox turns on the protection with ignore from the example above.
Try it:
The takeaway: Without the cleanup, whoever arrives last decides. With the cleanup, whoever was selected last decides – and that's what the user expects.
Loading the interactive part…
You're on the phone with a friend and halfway through the call you mute the ringer for other calls. You don't hang up and call them again because of that. Muting has nothing to do with the call in progress – it only affects what happens when someone else calls.
The same situation comes up in effects. The chat should reconnect when the room changes. When the user mutes notifications, we don't want to change the connection. But when a message arrives, we need to know whether it's muted now. If we put muted in the dependencies, every mute would disconnect and reconnect the chat. If we didn't, the effect would see the old value.
function ChatRoom({ roomId, muted }: { roomId: string; muted: boolean }) {// An effect event: always sees the current muted, but it isn't a dependencyconst onMessage = useEffectEvent((msg: string) => {if (!muted) playSound()showNotification(msg)})useEffect(() => {const connection = connect(roomId, onMessage)return () => connection.disconnect()}, [roomId]) // only the room – muting doesn't change the connection}
useEffectEvent turns a function into an "effect event": code the effect calls, but doesn't react to. Every time it's called, it sees the newest props and state.
What you see: A choice of chat room and a "Mute notifications" switch. The fake connection sends a message every 2.5 s. The log shows connecting (🔌), disconnecting (❌) and incoming messages (🔔 or 🔕).
Try it:
The takeaway: What's in the dependencies (the room) restarts the effect. What's in useEffectEvent (muting) the effect just reads whenever it needs to.
Loading the interactive part…
Effects are doors out of React. The most common mistake is to use them even where there's no "outside" – as if you went from the living room to the kitchen via the balcony. It works, but it's slower, and sooner or later you'll fall. Before you write useEffect, ask yourself: which system outside React am I syncing with here? If you have no answer, you probably don't need an effect.
// ❌ An effect chases state that can be calculatedconst [items, setItems] = useState<Item[]>([])const [total, setTotal] = useState(0)useEffect(() => {setTotal(items.reduce((sum, i) => sum + i.price, 0))}, [items])// ✅ Calculate it during renderconst total = items.reduce((sum, i) => sum + i.price, 0)
The effect version renders the component twice (first with the old total, then with the new one) and shows a wrong number for a split second. There's no outside system here – just a calculation.
// ❌ A click sets a flag and an effect "reacts" to itconst [submitted, setSubmitted] = useState(false)useEffect(() => {if (submitted) {sendOrder(cart)showToast('Ordered!')}}, [submitted])// ✅ Do it right where the event happenedfunction handleOrder() {sendOrder(cart)showToast('Ordered!')}
The order should be sent because the user clicked – not because the component rendered. The code belongs in the handler. The effect version is indirect and can run when you don't expect it (say, when the component is mounted again).
| Situation | ❌ An effect | ✅ A better solution |
|---|---|---|
| A value calculated from props/state | useEffect → setTotal(…) | Calculate it during render (useMemo if needed) |
| Reacting to a click / submit | Set a flag and react in an effect | Do it right in the handler |
| Resetting state when a prop changes | useEffect(() => setX(init), [id]) | <Form key={id} /> |
| Telling the parent about a change | useEffect(() => onChange(x), [x]) | Call onChange in the same handler as setX |
| Chaining state | An effect sets state that triggers another effect… | Calculate everything in one handler |
| Loading data | A bare fetch in an effect | TanStack Query / a router loader / use() |
One line in the effect. You'll see the result on the browser tab.
CHALLENGE: The click count in the tab title
1. In the effect, set document.title = `Clicked ${count}×`. The dependency is [count].
2. The cleanup is already written: it restores the original title when the component disappears.
You'll see the result at the top, on the browser tab.row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).Loading the interactive part…
The stopwatch practises sections 2 and 3 (an interval, cleanup, dependencies, an updater). The search adds section 4: you have to make sure only the results of the last query are shown. For every effect, first tell yourself: what am I switching on, how do I switch it off and what does it depend on.
An interval, a cleanup, an updater function and two independent effects – each syncs one thing (the time and the tab title).
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
A real scenario from every app: an autocomplete that doesn't flood the server with a query after every letter and doesn't show stale results.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
ignore or AbortController throws away the old order.