The lifecycle of an effect
See an effect start and stop synchronizing as a chat room changes. Learn which values are reactive, why each is a dependency, and why the linter is right.
- 01Think in start and stopDescribe an effect by how it starts and stops synchronizing, and predict each start and stop as props change and the component comes and goes.
- 02Find the reactive valuesTell props, state, and values worked out from them from module values, set functions, refs, and values outside React.
- 03Trust the dependency listList every reactive value an effect reads, read the linter's messages, and fix a dependency by changing the code, not by silencing it.
A chat room that follows the room you pick
In Synchronizing with effects, a chat room connected to a server in an effect, and its cleanup disconnected. This lesson follows one such effect over the whole life of a page: you switch rooms, close the chat, and open it again, and you watch every start and stop. chat.js, in the second tab, is a pretend server that prints them instead of connecting.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId }) { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}Line 4 is the server's address, outside any component. ChatRoom (lines 6 to 14) takes the room as a prop. Its effect (lines 7 to 11) makes a connection for this roomId (line 8), connects (line 9), and returns a cleanup that disconnects (line 10). Line 11 lists [roomId]. App keeps the room (line 17) and whether the chat is open (line 18), makes a button per room (lines 22 to 24), and shows ChatRoom only while the chat is open (line 27).
The console starts with Start, Stop, Start for general: in development, StrictMode stops and starts every new effect once, to check that it can. Now press travel. Two lines: Stop: disconnect from "general", then Start: connect to "travel". ChatRoom didn't leave the page; only its heading changed. Press travel again: nothing, because the room didn't change. Press music, then Close chat: a stop and a start, then one last stop.
One component, which appeared once and went away once. One effect, which started and stopped three times. They don't have the same life.
You switch a kitchen radio on in the morning and off at night. In between, you turn the dial three times: each turn stops one station and starts another. Nobody would say the radio was switched on three times. It was on once, and it played three stations.
- In real life: The radio, switched on and later off
- In React: ChatRoom, on the page and later removed
- In real life: Tuning to a station
- In React: The effect starting to synchronize with a room
- In real life: Turning the dial stops one station and starts the next
- In React: A new roomId runs the cleanup, then the effect again
- In real life: The station on the dial
- In React: The reactive value the effect reads, roomId
An effect has its own lifecycle. A component mounts, updates, and unmounts. An effect does only two things: it starts synchronizing with something, and later stops. React stops it and starts it again each time a value it reads changes, and stops it for good when the component goes away.
Start and stop, not mount and unmount
It's tempting to read an effect as "this runs when the component mounts, and the cleanup when it unmounts". That story breaks as soon as a prop changes. Read the effect on its own instead:
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);This fragment of ChatRoom isn't meant to run on its own. Lines 2 and 3 say how to start synchronizing with one room: connect to it. Line 4 says how to stop: disconnect. Line 5 says what it follows: roomId. Nothing in it mentions mounting. From the effect's point of view, the session above was:
- Start synchronizing with general.
- Stop synchronizing with general. Start synchronizing with travel.
- Stop synchronizing with travel. Start synchronizing with music.
- Stop synchronizing with music.
Write every effect so that it can start and stop like this, any number of times, for any value of what it reads. Then you never have to think about which moment in the component's life it is. The playground runs the same ChatRoom and draws both lives side by side.
roomId: "general"Welcome to the general room
1 of 7 columnsChatRoom on the page
Effect synchronized with
Pick travel, then music, then Close chat. Each action adds a column. Watch which lane changes.
Pick travel and music: the top lane keeps one bar, and the bottom lane gets a new bar for each room. Close chat ends both. Open chat starts a new ChatRoom, with a new bar in each lane. Pick a room while the chat is closed, and nothing happens until you open it.
ChatRoom stays on the page, its effect stops and starts again each time roomId changes. The component mounted once; the effect synchronized three times.Each row asks the same question of the component and of its effect:
| Question | The component | Its effect |
|---|---|---|
| What can happen to it? | It mounts, updates any number of times, and unmounts. | It starts synchronizing, and later stops. That can happen many times. |
| What makes it happen? | A parent shows it, its state or props change, or a parent removes it. | A render where a dependency changed, or the component leaving the page. |
| How many at once? | One component, on the page from mount to unmount. | One synchronization at a time per effect: the old one stops before the new one starts. |
| What does its code describe? | What the screen shows for the current props and state. | How to start, and how to stop, for the values of one render. |
This is also why StrictMode stops and starts every new effect once in development. If the effect survives a stop and a start straight away, it will survive one when the room changes too.
How React re-synchronizes
When you press travel, React renders ChatRoom with roomId "travel" and puts the new heading on the page. Then it compares the dependency list with last time, using Object.is: "general" and "travel" differ, so it re-synchronizes, in two steps:
- It calls the cleanup that the effect returned after the last render. That cleanup still holds "general".
- It runs the effect from the new render, which holds "travel".
roomId. When you pick travel, React runs the cleanup from render 1, then the effect from render 2.Each render makes its own effect function and its own cleanup, and each sees that render's roomId, the way any function remembers the variables around it where it was made (a closure). That's why the cleanup disconnects from the right room without you storing the old one anywhere.
The same two steps cover the component's whole life. When ChatRoom leaves the page, React calls the last cleanup and runs nothing after it. Close the chat, press general, and open it again: a brand-new ChatRoom appears, and its effect starts with "general", with StrictMode's check again, because the component is new.
Reactive values: props, state, and what you work out from them
roomId is a reactive value: a value declared inside the component, so a render can give it a new value. Props are reactive, state is reactive, and so is anything you work out from them in the component body. This chat lets you switch to a backup server:
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; function ChatRoom({ roomId }) { const [useBackup, setUseBackup] = useState(false); const serverUrl = useBackup ? "https://backup.example.com" : "https://chat.example.com"; useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [serverUrl, roomId]); return ( <> <h3>Welcome to the {roomId} room</h3> <label> <input type="checkbox" checked={useBackup} onChange={() => setUseBackup(!useBackup)} /> Use the backup server </label> </> );} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}Line 5 keeps useBackup in state. Line 6 works out serverUrl from it. serverUrl isn't state, but it's made again on every render from state, so it can change: it's reactive. The effect reads it, so line 12 lists it next to roomId.
Tick Use the backup server. The console prints Stop: disconnect from "general" at https://chat.example.com, then Start: connect to "general" at https://backup.example.com. Same room, new server, so the effect stops and starts again. Pick travel: it stops at the backup server and starts travel there.
current, can't be.Now move both values out of the component:
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com";const roomId = "general"; function ChatRoom() { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, []); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [show, setShow] = useState(true); return ( <> <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> {show && <ChatRoom />} </> );}Lines 4 and 5 are made once, when the module loads, and no render can change them. The effect reads nothing reactive, so its list on line 12 is empty, and the linter agrees. From the effect's point of view, [] means: start when ChatRoom appears, and stop when it goes. Press Close chat: one stop. That's the only time a list of dependencies means "mount and unmount", and it's because nothing in it can change.
Every reactive value is a dependency
Here is the backup-server chat with one change: line 12 lists only roomId.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; function ChatRoom({ roomId }) { const [useBackup, setUseBackup] = useState(false); const serverUrl = useBackup ? "https://backup.example.com" : "https://chat.example.com"; useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); return ( <> <h3>Welcome to the {roomId} room</h3> <label> <input type="checkbox" checked={useBackup} onChange={() => setUseBackup(!useBackup)} /> Use the backup server </label> </> );} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}Tick Use the backup server. Nothing prints. The checkbox is ticked, serverUrl is the backup address in this render, and the connection is still on the main server: React compared [roomId], found it the same, and left the effect alone. Now pick travel. The console prints Stop: disconnect from "general" at https://chat.example.com and Start: connect to "travel" at https://backup.example.com. The server change arrived late, carried in by a room change.
React's ESLint plugin, eslint-plugin-react-hooks, catches this before you run the code. Its rule react-hooks/exhaustive-deps reads the effect, finds every reactive value it uses, and compares them with the list. For line 12 it says:
React Hook useEffect has a missing dependency: 'serverUrl'. Either include it or remove the dependency array.You don't choose an effect's dependencies; its code does. Every reactive value it reads goes in the list. If you want a value to stop being a dependency, change the code so it isn't reactive, as the section on the linter shows.
What isn't reactive
Three kinds of value an effect can read without listing them:
- Values outside the component, like
serverUrlaboveChatRoom. They're made once. - Stable values React promises never to change: the set function from
useStateand the objectuseRefreturns. - Values made inside the effect itself. The effect makes them; it doesn't read them from the component.
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
const inputRef = useRef(null);
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
setMessages([]);
inputRef.current.focus();
return () => connection.disconnect();
}, [roomId]);
}This fragment isn't meant to run on its own. Line 8 calls setMessages and line 9 uses inputRef, and line 11 lists neither. React gives back the same setMessages and the same ref object on every render, so they can't make the effect start again. Listing them is allowed and changes nothing. The linter knows this and doesn't ask.
A ref's current is different. You change it by assigning to it, and that doesn't render anything. So it isn't reactive, and putting it in the list doesn't make it one:
import { useEffect, useRef, useState } from "react"; function ChatRoom({ roomId }) { const mutedRef = useRef(false); const [draft, setDraft] = useState(""); useEffect(() => { console.log(`Start: "${roomId}", muted: ${mutedRef.current}`); return () => console.log(`Stop: "${roomId}"`); }, [roomId, mutedRef.current]); return ( <> <button onClick={() => (mutedRef.current = !mutedRef.current)}>Mute</button>{" "} <input aria-label="Message" value={draft} onChange={(event) => setDraft(event.target.value)} /> </> );} export default function App() { return <ChatRoom roomId="general" />;}Line 4 keeps muted in a ref, and line 10 lists mutedRef.current. Line 14's Mute button flips it. Press Mute: nothing prints. Now type a letter in the box. The console prints Stop: "general" and Start: "general", muted: true. React only compares the list while it renders, and typing caused the next render, so the effect followed the mute late, and for the wrong reason. The linter flags line 10:
React Hook useEffect has an unnecessary dependency: 'mutedRef.current'. Either exclude it or remove the dependency array. Mutable values like 'mutedRef.current' aren't valid dependencies because mutating them doesn't re-render the component.If an effect must follow a value, the value must be state: exercise 5 makes that change. Values the browser changes, like location.pathname, are the same kind of trap:
function ChatRoom({ roomId }) {
useEffect(() => {
logVisit(roomId, location.pathname);
}, [roomId, location.pathname]);
}React Hook useEffect has an unnecessary dependency: 'location.pathname'. Either exclude it or remove the dependency array. Outer scope values like 'location.pathname' aren't valid dependencies because mutating them doesn't re-render the component.The browser changes location.pathname without telling React. To read it and render when it changes, subscribe to it with useSyncExternalStore, as You might not need an effect does for the online status. Each row says whether an effect must list a kind of value:
| A value the effect reads | Must it be listed? | Why |
|---|---|---|
A prop, like roomId | Yes | A parent can pass a new one on any render. |
State, like useBackup | Yes | Setting it renders the component with a new value. |
A value worked out from them, like serverUrl | Yes | It's made again on every render, from values that change. |
A const above the component | No | It's made once when the module loads and never changes. |
A set function, or the object useRef returns | No | React gives back the same one on every render. Listing it is allowed and changes nothing. |
A ref's current, or location.pathname | No, and it can't be | It changes without a render, so React would only see the change at some later, unrelated render. |
Why the linter is right
Every warning from react-hooks/exhaustive-deps points at a value that can change while the effect keeps using an old one. That's exactly the missing-server bug. The tempting fix is a comment that silences the rule:
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId]);Line 5 tells ESLint to skip the next line. The warning disappears and the bug stays: this fragment is the missing-server effect again. A silenced warning is where to look first when an effect uses old values. If you never silence the rule, you don't get that kind of bug.
When an effect re-synchronizes more than you want, change the code so the value isn't reactive. You have already seen one way: move it outside the component. The other is to move it inside the effect:
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; function ChatRoom({ roomId }) { useEffect(() => { const serverUrl = roomId === "music" ? "https://backup.example.com" : "https://chat.example.com"; const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}Line 6 picks the server inside the effect, from roomId: the music room uses the backup server. The effect reads only roomId from the component, so [roomId] on line 10 is the complete list, and the linter is quiet. Press music: Start: connect to "music" at https://backup.example.com.
Two harder cases get lessons of their own. Sometimes an effect needs the latest value of something without re-synchronizing when it changes; useEffectEvent, added in React 19.2, does that (Separating events from effects). And sometimes a dependency like an object or a function changes on every render; Removing effect dependencies shows how to change the code so it doesn't. Neither case is solved by silencing the linter.
One effect for each synchronization
A real chat also records a visit when you open a room, for analytics. logVisit, in analytics.js, prints a line instead of sending it. It's tempting to add the call to the effect you already have:
import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { logVisit } from "./analytics.js"; function ChatRoom({ roomId }) { const [useBackup, setUseBackup] = useState(false); const serverUrl = useBackup ? "https://backup.example.com" : "https://chat.example.com"; useEffect(() => { logVisit(roomId); const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [serverUrl, roomId]); return ( <> <h3>Welcome to the {roomId} room</h3> <label> <input type="checkbox" checked={useBackup} onChange={() => setUseBackup(!useBackup)} /> Use the backup server </label> </> );} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}Lines 9 to 14 record the visit and connect, in one effect, which lists both values it reads. It works until you tick Use the backup server: the console prints Visit: "general" again, between the stop and the start. Nobody visited anything; the server changed, and the visit came along because it shares an effect with the connection.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { logVisit } from "./analytics.js"; function ChatRoom({ roomId }) { const [useBackup, setUseBackup] = useState(false); const serverUrl = useBackup ? "https://backup.example.com" : "https://chat.example.com"; useEffect(() => { logVisit(roomId); }, [roomId]); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [serverUrl, roomId]); return ( <> <h3>Welcome to the {roomId} room</h3> <label> <input type="checkbox" checked={useBackup} onChange={() => setUseBackup(!useBackup)} /> Use the backup server </label> </> );} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}Lines 9 to 11 record a visit, and follow only roomId. Lines 13 to 17 keep the connection, and follow serverUrl and roomId. Tick the backup server: a stop and a start, and no visit. Pick travel: both effects run. Split effects by what they keep in step, not by when they happen to run. Each one then lists exactly what it reads, and starts and stops only for its own reasons.
roomId, a propuseBackup, a state variableconst serverUrl = useBackup ? backup : main;in the componentconst serverUrl = "https://chat.example.com";above the componentmutedRef.currentlocation.pathnamesetMessages, a set function from useState- A
constmade inside the effect itself
Sort each value an effect inside ChatRoom reads.
Common mix-ups
- "An effect runs on mount and its cleanup on unmount." No. It starts and stops each time a value it reads changes. Mount and unmount are only the first start and the last stop.
- "Leaving a value out of the list makes the effect run less." No. It makes the effect keep an old value, like the main server after you picked the backup.
- "An empty list means run once." No. It means the effect reads nothing reactive, so it starts and stops with the component. StrictMode still stops and starts it once in development.
- "Putting ref.current in the list makes React watch it." No. React only compares the list while rendering, and changing a ref doesn't render.
- "Silencing the linter is fine when you know the value won't change." No. If it can't change, move it outside the component and the warning goes away on its own.
- "Set functions must be in the list." No. They never change. Listing them is allowed and does nothing.
Practice exercises
Each exercise runs live with chat.js. Read the console under the result after every click.
The chat starts in general. You press travel, then music. Not counting StrictMode's check at the start, how many Stop lines print? Answer first, then press Edit & run.
import { useEffect, useState } from "react";
import { createConnection } from "./chat.js";
const serverUrl = "https://chat.example.com";
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h3>Welcome to the {roomId} room</h3>;
}
export default function App() {
const [roomId, setRoomId] = useState("general");
const [show, setShow] = useState(true);
return (
<>
<p>
{["general", "travel", "music"].map((room) => (
<button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button>
))}{" "}
<button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button>
</p>
{show && <ChatRoom roomId={roomId} />}
</>
);
}import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId }) { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}Two stops: Stop: disconnect from "general" when you pick travel, and Stop: disconnect from "travel" when you pick music. Each one comes right before the start of the new room.
The effect has an empty list, so ticking the backup server or picking a room does nothing. Fix line 12. What dependency array does the effect need?
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; function ChatRoom({ roomId }) { const [useBackup, setUseBackup] = useState(false); const serverUrl = useBackup ? "https://backup.example.com" : "https://chat.example.com"; useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, []); return ( <> <h3>Welcome to the {roomId} room</h3> <label> <input type="checkbox" checked={useBackup} onChange={() => setUseBackup(!useBackup)} /> Use the backup server </label> </> );} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; function ChatRoom({ roomId }) { const [useBackup, setUseBackup] = useState(false); const serverUrl = useBackup ? "https://backup.example.com" : "https://chat.example.com"; useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [serverUrl, roomId]); return ( <> <h3>Welcome to the {roomId} room</h3> <label> <input type="checkbox" checked={useBackup} onChange={() => setUseBackup(!useBackup)} /> Use the backup server </label> </> );} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}The effect reads serverUrl, worked out from state, and roomId, a prop. Both are reactive, so line 12 lists both: [serverUrl, roomId]. Now ticking the backup server and picking a room each stop and start the connection.
Ticking Use the backup server prints nothing. Make the effect follow the server. After your fix, you tick the box: which server does the new Start line name?
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; function ChatRoom({ roomId }) { const [useBackup, setUseBackup] = useState(false); const serverUrl = useBackup ? "https://backup.example.com" : "https://chat.example.com"; useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); return ( <> <h3>Welcome to the {roomId} room</h3> <label> <input type="checkbox" checked={useBackup} onChange={() => setUseBackup(!useBackup)} /> Use the backup server </label> </> );} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; function ChatRoom({ roomId }) { const [useBackup, setUseBackup] = useState(false); const serverUrl = useBackup ? "https://backup.example.com" : "https://chat.example.com"; useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [serverUrl, roomId]); return ( <> <h3>Welcome to the {roomId} room</h3> <label> <input type="checkbox" checked={useBackup} onChange={() => setUseBackup(!useBackup)} /> Use the backup server </label> </> );} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}With serverUrl in the list, ticking the box re-synchronizes straight away: Stop … at https://chat.example.com, then Start: connect to "general" at https://backup.example.com.
Unticking Chat on says "Chat is off", but the connection stays open. Make the effect stop synchronizing while isOn is false. After your fix, you untick the box: what does the console print?
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId, isOn }) { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h3>{isOn ? `Connected to ${roomId}` : "Chat is off"}</h3>;} export default function App() { const [isOn, setIsOn] = useState(true); return ( <> <label> <input type="checkbox" checked={isOn} onChange={() => setIsOn(!isOn)} /> Chat on </label> <ChatRoom roomId="general" isOn={isOn} /> </> );}import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId, isOn }) { useEffect(() => { if (!isOn) return; const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId, isOn]); return <h3>{isOn ? `Connected to ${roomId}` : "Chat is off"}</h3>;} export default function App() { const [isOn, setIsOn] = useState(true); return ( <> <label> <input type="checkbox" checked={isOn} onChange={() => setIsOn(!isOn)} /> Chat on </label> <ChatRoom roomId="general" isOn={isOn} /> </> );}Unticking renders with isOn false. It's in the list, so React runs the cleanup: Stop: disconnect from "general" at https://chat.example.com. The new effect returns early, so nothing starts until you tick the box again.
Pressing Mute doesn't restart the effect until you type. Make it restart right away, and quiet the linter. After your fix, you press Mute once: what is the last line in the console?
import { useEffect, useRef, useState } from "react"; function ChatRoom({ roomId }) { const mutedRef = useRef(false); const [draft, setDraft] = useState(""); useEffect(() => { console.log(`Start: "${roomId}", muted: ${mutedRef.current}`); return () => console.log(`Stop: "${roomId}"`); }, [roomId, mutedRef.current]); return ( <> <button onClick={() => (mutedRef.current = !mutedRef.current)}>Mute</button>{" "} <input aria-label="Message" value={draft} onChange={(event) => setDraft(event.target.value)} /> </> );} export default function App() { return <ChatRoom roomId="general" />;}import { useEffect, useState } from "react"; function ChatRoom({ roomId }) { const [muted, setMuted] = useState(false); const [draft, setDraft] = useState(""); useEffect(() => { console.log(`Start: "${roomId}", muted: ${muted}`); return () => console.log(`Stop: "${roomId}"`); }, [roomId, muted]); return ( <> <button onClick={() => setMuted(!muted)}>Mute</button>{" "} <input aria-label="Message" value={draft} onChange={(event) => setDraft(event.target.value)} /> </> );} export default function App() { return <ChatRoom roomId="general" />;}As state, muted is reactive: pressing Mute renders, React sees it changed, and the effect stops and starts at once, ending with Start: "general", muted: true. Typing no longer re-synchronizes.
Ticking the backup server records a visit that never happened. Split the effect so each synchronization has its own. After your fix, you tick Use the backup server: how many Visit lines print?
import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { logVisit } from "./analytics.js"; function ChatRoom({ roomId }) { const [useBackup, setUseBackup] = useState(false); const serverUrl = useBackup ? "https://backup.example.com" : "https://chat.example.com"; useEffect(() => { logVisit(roomId); const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [serverUrl, roomId]); return ( <> <h3>Welcome to the {roomId} room</h3> <label> <input type="checkbox" checked={useBackup} onChange={() => setUseBackup(!useBackup)} /> Use the backup server </label> </> );} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { logVisit } from "./analytics.js"; function ChatRoom({ roomId }) { const [useBackup, setUseBackup] = useState(false); const serverUrl = useBackup ? "https://backup.example.com" : "https://chat.example.com"; useEffect(() => { logVisit(roomId); }, [roomId]); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [serverUrl, roomId]); return ( <> <h3>Welcome to the {roomId} room</h3> <label> <input type="checkbox" checked={useBackup} onChange={() => setUseBackup(!useBackup)} /> Use the backup server </label> </> );} export default function App() { const [roomId, setRoomId] = useState("general"); const [show, setShow] = useState(true); return ( <> <p> {["general", "travel", "music"].map((room) => ( <button key={room} aria-pressed={room === roomId} onClick={() => setRoomId(room)}>{room}</button> ))}{" "} <button onClick={() => setShow(!show)}>{show ? "Close chat" : "Open chat"}</button> </p> {show && <ChatRoom roomId={roomId} />} </> );}The visit effect lists only roomId, so a server change doesn't run it: ticking the box prints a stop and a start, and 0 Visit lines.
Check your understanding
Read each effect on its own: what it starts, what it stops, and which values can make it do that again.
Question 1 of 7From the effect's own point of view, what does it do?
Choose an answer to see the explanation.
Question 2 of 7ChatRoom is connected to general. You pick travel. What does chat.js print, in order?
Read the code, then predictJSXfunction ChatRoom({ roomId }) { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h3>Welcome to the {roomId} room</h3>; }Choose an answer to see the explanation.
Question 3 of 7Which values in this ChatRoom are reactive?
Read the code, then predictJSXconst serverUrl = "https://chat.example.com"; function ChatRoom({ roomId }) { const [useBackup, setUseBackup] = useState(false); const room = roomId.toLowerCase(); const inputRef = useRef(null); // ... }Choose an answer to see the explanation.
Question 4 of 7Why can't
mutedRef.currentbe a dependency?Choose an answer to see the explanation.
Question 5 of 7An effect reads only values declared above the component, and its array is
[]. When does it start and stop?Choose an answer to see the explanation.
Question 6 of 7The linter says
serverUrlis a missing dependency. What's a right fix?Choose an answer to see the explanation.
Question 7 of 7Does an effect that calls
setMessageshave to list it?Choose an answer to see the explanation.
Key takeaways
- An effect starts synchronizing and later stops. It can do that many times while its component stays on the page.
- When a dependency changes, React runs the cleanup from the last render, then the effect from the new one.
- Reactive values are props, state, and values worked out from them in the component body.
- Every reactive value an effect reads must be in its list. Leaving one out keeps an old value.
- Module values, set functions, and ref objects aren't reactive. A ref's current value and browser values can't be dependencies.
- The linter is right: fix a warning by changing the code, never by silencing it. Give each synchronization its own effect.
An effect starts and stops; React restarts it when what it reads changes.
List everything it reads, and let the code decide the list.
Coming next: Separating events from effects. It shows how an effect can read the latest value of something without starting again when it changes.