Removing effect dependencies
Stop needless Effect re-runs by changing the code, not the dependency list: event handlers, split Effects, updaters, Effect Events, and objects.
- 01Find why an Effect re-runsRead an Effect's dependencies and tell which ones change when nothing it does should change.
- 02Remove a dependency by changing the codeMove code into a handler, split the Effect, use an updater or an Effect Event, so the linter agrees the value isn't needed.
- 03Keep objects and functions from re-syncingBuild them inside the Effect, outside the component, or depend on the strings and numbers they hold.
A chat that reconnects on every keystroke
This chat room keeps its server settings in an options object and connects with an Effect. The chat.js tab is a pretend server that numbers every connection, so a needless reconnect is easy to spot. Type a few letters in the message box and watch the console under the result.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId }) { const [message, setMessage] = useState(""); const options = { serverUrl, roomId }; useEffect(() => { const connection = createConnection(options); connection.connect(); return () => connection.disconnect(); }, [options]); return ( <label> Message <input value={message} onChange={(e) => setMessage(e.target.value)} /> </label> );} export default function App() { const [roomId, setRoomId] = useState("general"); return ( <div> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <h3>Room: {roomId}</h3> <ChatRoom roomId={roomId} /> </div> );}Line 9 builds options from serverUrl and roomId. The Effect on lines 11 to 15 reads it, so line 15 lists [options]: every value the Effect reads is in the list. The first render logs connections 1 and 2, the development check from Synchronizing with effects. Then every letter you type logs Disconnected from general and a new connection: 3, 4, 5. The room never changed.
Typing changes message, so ChatRoom renders again, and line 9 runs again. It makes a brand-new object. React compares each dependency with the last render's using Object.is, and two objects made separately are never equal, even when they hold the same things. So React decides options changed and re-syncs.
The linter sees this coming. It has nothing to add to line 15, but it warns about line 9: The 'options' object makes the dependencies of useEffect Hook (at line 15) change on every render. Move it inside the useEffect callback. In plain words: this object is new on every render, so the Effect will run after every render. The section on objects and functions, further down, makes that change.
options object. It holds the same things, but Object.is compares objects by identity, so it says false and React reconnects. The string "general" is equal to itself in every render.A warehouse sends a van whenever a parcel has a tracking number it hasn't seen. A clerk repacks the same book in a fresh box every hour, and each box gets a new number. The warehouse never opens the boxes, so it sends a van every hour, for the same book. If the clerk just kept the address on a label, the warehouse would see the same address and send nothing.
- In real life: The warehouse checks only the tracking number
- In React: React compares dependencies with
Object.is - In real life: The same book, packed in a new box with a new number
- In React: A new
{ serverUrl, roomId }object on every render - In real life: A new number means a new delivery run
- In React: A changed dependency means the Effect runs again
- In real life: Writing just the address on the label
- In React: Depending on the string
roomIdinstead
The dependency array must list every reactive value the Effect reads; you don't choose it. To make an Effect run less often, change its code so it no longer needs the value, and then the linter agrees the value can go.
Change the code, not the dependency list
The quick way out is to tell the linter to be quiet. Here a product review box saves your draft every 2 seconds, and someone silenced the linter to keep the timer from restarting. Type Great tea and watch what gets saved.
import { useEffect, useState } from "react";import { saveDraft } from "./drafts.js"; export default function App() { const [draft, setDraft] = useState(""); useEffect(() => { const id = setInterval(() => saveDraft(draft), 2000); return () => clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return <input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Write a review" />;}Line 8 starts a timer that saves draft. Line 10 silences the linter, so line 11 says []. The console keeps printing Saved draft: "": the timer's callback is a closure from the first render, when draft was empty, and with [] React never replaces it. The silenced linter can't warn you, so the bug hides until someone loses a review.
Do what the linter asked, and list draft:
import { useEffect, useState } from "react";import { saveDraft } from "./drafts.js"; export default function App() { const [draft, setDraft] = useState(""); useEffect(() => { const id = setInterval(() => saveDraft(draft), 2000); return () => clearInterval(id); }, [draft]); return <input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Write a review" />;}Line 10 lists [draft]. Each letter re-runs the Effect: the cleanup on line 9 stops the old timer and a new one starts with the new text. Type, stop, and 2 seconds later the console says Saved draft: "Great tea". Restarting here is fine: the timer really should follow the text.
When a re-run really isn't wanted, as with the chat above, don't edit the array. Change the code, until the value isn't needed any more. The rest of this lesson is five questions to ask about the value that keeps changing.
How to read the table: each row is a reason an Effect re-runs, the change to the code, and what the array becomes.
| What makes it re-run | How to change the code | What the array becomes |
|---|---|---|
| A value only a click should use, like the theme after an order. | Move the code into that click's event handler. | The value leaves the array, or the Effect goes away. |
| A value that a second, unrelated job reads. | Split the Effect into one Effect per job. | Each Effect lists only what its own job reads. |
| State the Effect reads only to work out the next state. | Pass an updater function to the set function. | The state leaves the array. |
| A value it should read but not react to. | Read it inside an Effect Event. | The value leaves the array. |
| An object or function made during render. | Make it inside the Effect or outside the component, or depend on the strings and numbers it holds. | Only values that stay equal when they don't really change. |
Code a click causes goes in the handler
First question: should this code run at all when the value changes? This order page posts the order from an Effect once submitted is true, and shows a thank-you in the current theme. Click Place order, then the theme button.
import { useEffect, useState } from "react";import { post } from "./api.js"; export default function App() { const [submitted, setSubmitted] = useState(false); const [theme, setTheme] = useState("light"); useEffect(() => { if (submitted) { post("/api/order"); console.log(`Thanks shown in ${theme}`); } }, [submitted, theme]); return ( <div> <button onClick={() => setSubmitted(true)}>Place order</button> <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>Theme: {theme}</button> </div> );}The Effect on lines 8 to 13 reads submitted and theme, so line 13 lists both. Placing the order logs POST /api/order. Changing the theme runs the Effect again, submitted is still true, and line 10 posts the order a second time. No list of dependencies fixes this, because the code shouldn't be reacting to anything: the click is the reason. As You might not need an effect showed, it belongs in the handler:
import { useState } from "react";import { post } from "./api.js"; export default function App() { const [theme, setTheme] = useState("light"); function handleOrder() { post("/api/order"); console.log(`Thanks shown in ${theme}`); } return ( <div> <button onClick={handleOrder}>Place order</button> <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>Theme: {theme}</button> </div> );}handleOrder on lines 7 to 10 posts and reads theme at the moment of the click. Line 14 calls it. The Effect, and its dependencies, are gone, and changing the theme logs nothing.
Two unrelated jobs need two Effects
Second question: is the Effect doing two separate jobs? This chat connects to a room and also shows the unread count in the tab title, through the title.js tab. Click New message a few times.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { updateTitle } from "./title.js"; const serverUrl = "https://chat.example.com"; export default function App() { const [roomId, setRoomId] = useState("general"); const [unread, setUnread] = useState(0); useEffect(() => { const connection = createConnection({ serverUrl, roomId }); connection.connect(); updateTitle(`(${unread}) ${roomId}`); return () => connection.disconnect(); }, [roomId, unread]); return ( <div> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <button onClick={() => setUnread(unread + 1)}>New message</button> <p>{unread} unread in {roomId}</p> </div> );}Lines 12 and 13 connect, and line 14 updates the title. Because both live in one Effect, line 16 lists roomId and unread, and every new message logs Disconnected from general and a new connection, just to change a title. Give each job its own Effect:
import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { updateTitle } from "./title.js"; const serverUrl = "https://chat.example.com"; export default function App() { const [roomId, setRoomId] = useState("general"); const [unread, setUnread] = useState(0); useEffect(() => { const connection = createConnection({ serverUrl, roomId }); connection.connect(); return () => connection.disconnect(); }, [roomId]); useEffect(() => { updateTitle(`(${unread}) ${roomId}`); }, [unread, roomId]); return ( <div> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <button onClick={() => setUnread(unread + 1)}>New message</button> <p>{unread} unread in {roomId}</p> </div> );}The connection on lines 11 to 15 lists only [roomId]. The title on lines 17 to 19 lists [unread, roomId], because it reads both. Now New message logs only Title: (1) general. Switch room runs both, because both jobs need the new room.
unread is a dependency of the connection too, so each new message reconnects. On the right, each job has its own Effect and its own array, and a new message only runs the title's Effect.Split by job, not by line count: if two pieces of code must always run together with the same values, they're one job and stay in one Effect.
Use an updater to read the previous state
Third question: does the Effect read some state only to work out the next state? This chat adds each incoming message to a list. The Receive a message button stands in for someone else sending one: it calls receive from chat.js. Click it twice.
import { useEffect, useState } from "react";import { createConnection, receive } from "./chat.js"; const serverUrl = "https://chat.example.com"; export default function App() { const [messages, setMessages] = useState([]); useEffect(() => { const connection = createConnection({ serverUrl, roomId: "general" }); connection.on("message", (text) => { setMessages([...messages, text]); }); connection.connect(); return () => connection.disconnect(); }, [messages]); return ( <div> <button onClick={() => receive("Asha: Is the tea ready?")}>Receive a message</button> <ul> {messages.map((text, index) => ( <li key={index}>{text}</li> ))} </ul> </div> );}Line 12 builds the new list from messages, so line 16 has to list [messages]. Each message changes messages, which re-runs the Effect: disconnect, connection 3; disconnect, connection 4. A busy room would reconnect for every message. Pass an updater function instead, as in Queueing a series of state updates:
import { useEffect, useState } from "react";import { createConnection, receive } from "./chat.js"; const serverUrl = "https://chat.example.com"; export default function App() { const [messages, setMessages] = useState([]); useEffect(() => { const connection = createConnection({ serverUrl, roomId: "general" }); connection.on("message", (text) => { setMessages((list) => [...list, text]); }); connection.connect(); return () => connection.disconnect(); }, []); return ( <div> <button onClick={() => receive("Asha: Is the tea ready?")}>Receive a message</button> <ul> {messages.map((text, index) => ( <li key={index}>{text}</li> ))} </ul> </div> );}Line 12 now passes (list) => [...list, text]. React calls it with the latest list, so the Effect never reads messages, and line 16 is [], with the linter's agreement. Receiving messages adds them to the page and logs nothing.
messages to build the next list, so messages is a dependency and every message reconnects. At the bottom, React hands the updater the latest list, and the Effect doesn't read messages at all.Read a value without reacting to it
Fourth question: does the Effect need a value's latest version without needing to re-sync when it changes? That's what an Effect Event is for, as Separating events from effects showed with a theme. A common case is a function passed down as a prop. Here App passes onMessage to count unread messages. Click Receive a message twice.
import { useEffect, useState } from "react";import { createConnection, receive } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId, onMessage }) { useEffect(() => { const connection = createConnection({ serverUrl, roomId }); connection.on("message", (text) => onMessage(text)); connection.connect(); return () => connection.disconnect(); }, [roomId, onMessage]); return <button onClick={() => receive("Asha: Hi!")}>Receive a message</button>;} export default function App() { const [unread, setUnread] = useState(0); return ( <div> <p>Unread: {unread}</p> <ChatRoom roomId="general" onMessage={() => setUnread(unread + 1)} /> </div> );}Line 9 calls onMessage, so line 12 lists it. But line 23 creates a new arrow function every time App renders, and each message renders App, since unread changes. So every message reconnects the chat. Wrap the prop in an Effect Event inside ChatRoom:
import { useEffect, useEffectEvent, useState } from "react";import { createConnection, receive } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId, onMessage }) { const onReceive = useEffectEvent(onMessage); useEffect(() => { const connection = createConnection({ serverUrl, roomId }); connection.on("message", (text) => onReceive(text)); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <button onClick={() => receive("Asha: Hi!")}>Receive a message</button>;} export default function App() { const [unread, setUnread] = useState(0); return ( <div> <p>Unread: {unread}</p> <ChatRoom roomId="general" onMessage={() => setUnread(unread + 1)} /> </div> );}Line 7 makes onReceive from onMessage. Line 11 calls it, and line 14 lists only [roomId]. The count still goes up, because an Effect Event always calls the latest onMessage, but the chat stays connected.
Objects and functions made during render
Fifth question: is the dependency an object or a function that the component body makes? That was the overview's problem. How to read the table: each row compares a value from two renders, what Object.is says, and why.
| What you compare | Object.is says | Why |
|---|---|---|
"general" and "general" | true | Strings are compared by what they say. |
{ serverUrl, roomId } made in render 1 and in render 2 | false | Two objects made separately are different objects, even with the same contents. |
createOptions made in render 1 and in render 2 | false | Each render creates a new function. |
options declared outside the component | true | It's made once, so both renders see the same object. |
The simplest fix is to build the object where it's used, inside the Effect. Then the Effect reads roomId, a string, instead of options:
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId }) { const [message, setMessage] = useState(""); useEffect(() => { const options = { serverUrl, roomId }; const connection = createConnection(options); connection.connect(); return () => connection.disconnect(); }, [roomId]); return ( <label> Message <input value={message} onChange={(e) => setMessage(e.target.value)} /> </label> );} export default function App() { const [roomId, setRoomId] = useState("general"); return ( <div> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <h3>Room: {roomId}</h3> <ChatRoom roomId={roomId} /> </div> );}Line 10 makes options inside the Effect, and line 14 lists [roomId]. Type in the box: nothing. Switch the room: one disconnect and one connect, which is the re-sync you want. Functions work the same way. A function declared in the component body is a new function on every render, so an Effect that depends on it re-runs every time. Move it inside the Effect too. Try all three versions in the playground: type a few letters, then switch rooms, and watch Connections made.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId }) { const [message, setMessage] = useState(""); const options = { serverUrl, roomId }; useEffect(() => { const connection = createConnection(options); connection.connect(); return () => connection.disconnect(); }, [options]); return ( <label> Message <input value={message} onChange={(e) => setMessage(e.target.value)} /> </label> );} export default function App() { const [roomId, setRoomId] = useState("general"); return ( <div> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <h3>Room: {roomId}</h3> <ChatRoom roomId={roomId} /> </div> );}Connections made: 2Room: general
- Connection 1: connected to general
- Disconnected from general
- Connection 2: connected to general
ChatRoom appeared and connected. In development, StrictMode disconnected and connected once more.
Two more fixes cover the other cases. If the object doesn't use props or state at all, declare it outside the component: it's made once, isn't reactive, and doesn't belong in the array (exercise 2). And if the object comes from a parent as a prop, you can't move it, but you can read the strings inside it before the Effect:
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; function ChatRoom({ options }) { const { serverUrl, roomId } = options; useEffect(() => { const connection = createConnection({ serverUrl, roomId }); connection.connect(); return () => connection.disconnect(); }, [serverUrl, roomId]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [note, setNote] = useState(""); return ( <div> <input value={note} onChange={(e) => setNote(e.target.value)} placeholder="A note to yourself" /> <ChatRoom options={{ serverUrl: "https://chat.example.com", roomId: "general" }} /> </div> );}Line 22 passes a new options object every time App renders, and typing a note renders it. Line 5 takes serverUrl and roomId out of it with destructuring, and line 11 lists those two strings. They're equal from one render to the next, so typing a note logs nothing.
A stock badge that subscribes once per product
Real Effects often need several of these changes at once. This badge subscribes to stock updates for a product, through the stock.js tab. It keeps a list of sales and tells its parent when stock runs low. Click Sell one three times, then switch the product.
import { useEffect, useEffectEvent, useState } from "react";import { sell, subscribeToStock } from "./stock.js"; function StockBadge({ productId, warehouse, onLowStock }) { const [sales, setSales] = useState([]); const onLow = useEffectEvent(onLowStock); useEffect(() => { const options = { productId, warehouse }; return subscribeToStock(options, (count) => { setSales((list) => [...list, `${productId} ${count}`]); if (count < 3) onLow(count); }); }, [productId, warehouse]); return ( <p> <button onClick={() => sell(productId)}>Sell one</button> Left after each sale: {sales.join(", ") || "none sold yet"} </p> );} export default function App() { const [productId, setProductId] = useState("tea"); const [alerts, setAlerts] = useState(0); return ( <div> <button onClick={() => setProductId(productId === "tea" ? "cake" : "tea")}>Product: {productId}</button> <p>Low-stock alerts: {alerts}</p> <StockBadge productId={productId} warehouse="Leeds" onLowStock={(count) => { console.log(`Low stock: ${count} ${productId} left`); setAlerts(alerts + 1); }} /> </div> );}The Effect on lines 8 to 14 depends only on what the subscription really needs, thanks to three changes:
- Line 9 builds
optionsinside the Effect, so the array lists the stringsproductIdandwarehouse, not a new object. - Line 11 adds a sale with an updater, so
salesisn't a dependency. - Line 6 wraps the parent's
onLowStockin an Effect Event. Lines 34 to 37 create a new function on everyApprender, and each alert rendersApp, but the subscription doesn't care.
The console shows Subscribed to tea in Leeds (twice in development), then only Low stock: 2 tea left on the third sale. Switching the product logs Unsubscribed from tea and Subscribed to cake in Leeds: the one re-sync that's needed.
- The order is posted again when the theme changes
- A coupon is applied again when the cart's currency changes
- A new unread message reconnects the chat, because the title code shares its Effect
- Switching tabs re-sends the page view, because it shares an Effect with the tab's data
- Each new message reconnects, because the Effect reads
messagesto add one - Muting the chat reconnects it
- Typing reconnects, because
optionsis built in the component body - Typing reconnects, because the Effect depends on
createOptions
Each card describes an Effect that re-runs when it shouldn't. Sort it by the change that fixes it.
Common mix-ups
- "I decide what goes in the dependency array." No. The code decides: every reactive value the Effect reads. Change the code, and the list changes with it.
- "Silencing the linter is fine if I know what I'm doing." No. The Effect keeps old values, and the linter stops warning you about every later change too.
- "An object with the same contents is the same dependency." No.
Object.iscompares objects by identity. A new object every render is a change every render. - "Functions are safe dependencies, because their code doesn't change." No. A function declared in the component body is a new function on every render.
- "Putting more in one Effect means fewer Effects to worry about." No. One Effect re-runs for all its dependencies. Give separate jobs separate Effects.
- "
[]is always the goal." No. The goal is an array that matches the code. An Effect that should followroomIdmust list it.
Practice exercises
Each exercise shows its result under the code, with the console under it. Change the code, and the result runs again as you type. For each one, find the value that keeps changing, and ask which of the five questions fits.
Watch the console for a few seconds. Which line does it print every second? Then make the timer start only once.
import { useEffect, useState } from "react"; export default function App() { const [seconds, setSeconds] = useState(0); useEffect(() => { console.log("Starting the timer"); const id = setInterval(() => { setSeconds(seconds + 1); }, 1000); return () => clearInterval(id); }, [seconds]); return <p>The tea has brewed for {seconds} seconds.</p>;}import { useEffect, useState } from "react"; export default function App() { const [seconds, setSeconds] = useState(0); useEffect(() => { console.log("Starting the timer"); const id = setInterval(() => { setSeconds((s) => s + 1); }, 1000); return () => clearInterval(id); }, []); return <p>The tea has brewed for {seconds} seconds.</p>;}With [seconds], every tick changed seconds, so React stopped the timer and started a new one, logging Starting the timer every second. The updater gets the latest value from React, so the Effect reads nothing and starts once.
Type one letter in the question box. What's the first line it logs? Then stop typing from reconnecting the support chat.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; export default function App() { const [question, setQuestion] = useState(""); const options = { serverUrl: "https://chat.example.com", roomId: "support" }; useEffect(() => { const connection = createConnection(options); connection.connect(); return () => connection.disconnect(); }, [options]); return ( <label> Question <input value={question} onChange={(e) => setQuestion(e.target.value)} /> </label> );}import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; const options = { serverUrl: "https://chat.example.com", roomId: "support" }; export default function App() { const [question, setQuestion] = useState(""); useEffect(() => { const connection = createConnection(options); connection.connect(); return () => connection.disconnect(); }, []); return ( <label> Question <input value={question} onChange={(e) => setQuestion(e.target.value)} /> </label> );}Each letter rendered App, which made a new options object, so React disconnected from support and connected again. Declared outside the component, options is made once and isn't reactive, so the array is [] and typing logs nothing.
Typing a note reconnects the chat. Fix ChatRoom without changing App. After your fix, how many lines does one letter in the note log?
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; function ChatRoom({ options }) { useEffect(() => { const connection = createConnection(options); connection.connect(); return () => connection.disconnect(); }, [options]); return <h3>Welcome to the {options.roomId} room</h3>;} export default function App() { const [note, setNote] = useState(""); return ( <div> <input value={note} onChange={(e) => setNote(e.target.value)} placeholder="A note to yourself" /> <ChatRoom options={{ serverUrl: "https://chat.example.com", roomId: "general" }} /> </div> );}import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; function ChatRoom({ options }) { const { serverUrl, roomId } = options; useEffect(() => { const connection = createConnection({ serverUrl, roomId }); connection.connect(); return () => connection.disconnect(); }, [serverUrl, roomId]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [note, setNote] = useState(""); return ( <div> <input value={note} onChange={(e) => setNote(e.target.value)} placeholder="A note to yourself" /> <ChatRoom options={{ serverUrl: "https://chat.example.com", roomId: "general" }} /> </div> );}The strings serverUrl and roomId are equal from one render to the next, even though options is a new object. With them as the dependencies, typing a note logs nothing.
Type an email, click Subscribe, then add one more letter. How many POST lines does that one letter add? Then make it post only when the button is clicked.
import { useEffect, useState } from "react";import { post } from "./api.js"; export default function App() { const [email, setEmail] = useState(""); const [subscribed, setSubscribed] = useState(false); useEffect(() => { if (subscribed) { post(`/api/subscribe?email=${email}`); } }, [subscribed, email]); return ( <div> <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" /> <button onClick={() => setSubscribed(true)}>Subscribe</button> </div> );}import { useState } from "react";import { post } from "./api.js"; export default function App() { const [email, setEmail] = useState(""); function handleSubscribe() { post(`/api/subscribe?email=${email}`); } return ( <div> <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" /> <button onClick={handleSubscribe}>Subscribe</button> </div> );}After subscribing, every edit to email re-ran the Effect, and subscribed was still true, so each letter posted again: one more POST per letter. In the click handler, it posts once per click.
Click Like the room once. What's the last line it logs? Then make liking leave the connection alone.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId, onConnected }) { useEffect(() => { const connection = createConnection({ serverUrl, roomId }); connection.connect(); onConnected(); return () => connection.disconnect(); }, [roomId, onConnected]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [likes, setLikes] = useState(0); return ( <div> <button onClick={() => setLikes(likes + 1)}>Like the room ({likes})</button> <ChatRoom roomId="general" onConnected={() => console.log(`Welcome! Likes so far: ${likes}`)} /> </div> );}import { useEffect, useEffectEvent, useState } from "react";import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId, onConnected }) { const onOpen = useEffectEvent(onConnected); useEffect(() => { const connection = createConnection({ serverUrl, roomId }); connection.connect(); onOpen(); return () => connection.disconnect(); }, [roomId]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [likes, setLikes] = useState(0); return ( <div> <button onClick={() => setLikes(likes + 1)}>Like the room ({likes})</button> <ChatRoom roomId="general" onConnected={() => console.log(`Welcome! Likes so far: ${likes}`)} /> </div> );}Each like rendered App, which passed a new onConnected, so the Effect disconnected, connected, and welcomed you again: Welcome! Likes so far: 1. The Effect Event calls the latest onConnected without being a dependency, so a like logs nothing.
Type Hi in the message box. What's the last line the console logs? Then stop typing from reconnecting.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId }) { const [message, setMessage] = useState(""); function createOptions() { return { serverUrl, roomId }; } useEffect(() => { const connection = createConnection(createOptions()); connection.connect(); return () => connection.disconnect(); }, [createOptions]); return ( <label> Message <input value={message} onChange={(e) => setMessage(e.target.value)} /> </label> );} export default function App() { const [roomId, setRoomId] = useState("general"); return ( <div> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <h3>Room: {roomId}</h3> <ChatRoom roomId={roomId} /> </div> );}import { useEffect, useState } from "react";import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId }) { const [message, setMessage] = useState(""); useEffect(() => { function createOptions() { return { serverUrl, roomId }; } const connection = createConnection(createOptions()); connection.connect(); return () => connection.disconnect(); }, [roomId]); return ( <label> Message <input value={message} onChange={(e) => setMessage(e.target.value)} /> </label> );} export default function App() { const [roomId, setRoomId] = useState("general"); return ( <div> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <h3>Room: {roomId}</h3> <ChatRoom roomId={roomId} /> </div> );}Each letter made a new createOptions, so the Effect reconnected: connections 3 and 4 for Hi, ending with Connection 4: connected to general. Inside the Effect, the function is made only when the Effect runs, and the Effect reads just roomId.
Check your understanding
Question 1 of 8The linter says your Effect is missing
roomId. What should you do?Choose an answer to see the explanation.
Question 2 of 8This chat has just appeared, and StrictMode made connections 1 and 2. You type one letter. What does the console print?
Read the code, then predictJSXimport { useEffect, useState } from "react"; import { createConnection } from "./chat.js"; const serverUrl = "https://chat.example.com"; function ChatRoom({ roomId }) { const [message, setMessage] = useState(""); const options = { serverUrl, roomId }; useEffect(() => { const connection = createConnection(options); connection.connect(); return () => connection.disconnect(); }, [options]); return ( <label> Message <input value={message} onChange={(e) => setMessage(e.target.value)} /> </label> ); }Choose an answer to see the explanation.
Question 3 of 8Why does React treat an
optionsobject made in the component body as changed after every render?Choose an answer to see the explanation.
Question 4 of 8Which dependency array does this Effect need?
Read the code, then predictJSXuseEffect(() => { const connection = createConnection({ serverUrl, roomId }); connection.on("message", (text) => { setMessages((list) => [...list, text]); }); connection.connect(); return () => connection.disconnect(); }, [/* ? */]);Choose an answer to see the explanation.
Question 5 of 8You click Place order, then click the theme button. What does the console print after the second click?
Read the code, then predictimport { useEffect, useState } from "react"; import { post } from "./api.js"; export default function App() { const [submitted, setSubmitted] = useState(false); const [theme, setTheme] = useState("light"); useEffect(() => { if (submitted) { post("/api/order"); console.log(`Thanks shown in ${theme}`); } }, [submitted, theme]); return ( <div> <button onClick={() => setSubmitted(true)}>Place order</button> <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>Theme: {theme}</button> </div> ); }Choose an answer to see the explanation.
Question 6 of 8In this review box, the linter was silenced. You type Great tea and wait 2 seconds. What does the console print?
Read the code, then predictimport { useEffect, useState } from "react"; import { saveDraft } from "./drafts.js"; export default function App() { const [draft, setDraft] = useState(""); useEffect(() => { const id = setInterval(() => saveDraft(draft), 2000); return () => clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return <input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Write a review" />; }Choose an answer to see the explanation.
Question 7 of 8Which dependency array does this support chat's Effect need?
Read the code, then predictJSXconst options = { serverUrl: "https://chat.example.com", roomId: "support" }; function SupportChat() { useEffect(() => { const connection = createConnection(options); connection.connect(); return () => connection.disconnect(); }, [/* ? */]); // … }Choose an answer to see the explanation.
Question 8 of 8A parent passes
onMessage={() => setUnread(unread + 1)}to ChatRoom, and ChatRoom's Effect listsonMessage. How do you stop it reconnecting every time the parent renders?Choose an answer to see the explanation.
Key takeaways
- The dependency array lists every reactive value the Effect reads. The code decides it, not you.
- Never silence the linter: the Effect keeps old values, and later mistakes go unwarned.
- Code a click causes goes in the handler. Separate jobs go in separate Effects.
- Use an updater to read the previous state, and an Effect Event to read a value without reacting to it.
- Objects and functions made during render are new every time. Build them inside the Effect or outside the component, or depend on the strings and numbers inside.
To remove a dependency, prove the Effect doesn't need it.
Change the code until the value isn't read, and the array follows.
Coming next: Layout effects and timing. It looks at when Effects run compared with the browser's paint, and the rare cases that need useLayoutEffect.