Separating events from effects
Keep an Effect from re-running when a value it only reads changes: tell reactive from non-reactive logic, and use React 19.2's useEffectEvent.
- 01Tell the two kinds of logic apartDecide whether code should react to a changed value, like connecting to a room, or only read it, like a notification's theme.
- 02Write an Effect EventMove non-reactive logic out of an Effect with
useEffectEvent, so the Effect re-runs only for the values it really depends on. - 03Follow its rulesCall Effect Events only from Effects, keep them out of dependency arrays and props, and never use them to hide a real dependency.
A chat that reconnects when you change the theme
A chat room connects to its server with an Effect, as in Synchronizing with effects. When the connection is ready, it shows a small notification in the corner, in the page's light or dark colors. The chat.js tab is a pretend server that takes 2 seconds to connect; notifications.js shows the notification and logs it. Wait for the first notification, then click Dark theme and watch the console under the result.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection(roomId); connection.on("connected", () => { showNotification(`Connected to ${roomId}`, theme); }); connection.connect(); return () => connection.disconnect(); }, [roomId, theme]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [roomId, setRoomId] = useState("general"); const [isDark, setIsDark] = useState(false); return ( <div className={isDark ? "page dark" : "page"}> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button> <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} /> </div> );}Lines 6 to 13 are one Effect. Line 7 creates a connection to roomId, and line 11 starts connecting. Lines 8 to 10 tell the connection what to do once it's ready: line 9 shows a notification in the current theme. The Effect reads both roomId and theme, so line 13 lists both, as the linter asks.
Clicking Dark theme logs Disconnected from general and Connecting to general..., and 2 seconds later a new notification. The room didn't change. Nobody asked to reconnect. But theme is a dependency, so React ran the cleanup and the whole Effect again. On a real server, that's a dropped connection every time someone changes a setting.
theme is a dependency of the whole Effect, so a new theme reconnects the chat. On the right, only connecting is in the Effect. The notification moved into an Effect Event, which reads the theme without being a dependency.The Effect holds two kinds of logic. Connecting should follow roomId: a new room means a new connection. Showing the notification should only read theme at the moment the chat connects: a new theme means nothing to the connection. This lesson shows how to tell the two apart, and how React 19.2's useEffectEvent moves the second kind out of the Effect, so it stops being a dependency.
You call the number on a note by the phone. If someone writes a new number on the note, you hang up and dial the new one. While it rings, your mood changes from tired to cheerful. When your friend picks up, you greet them in the mood you're in right then. You don't hang up and redial because your mood changed; the number is what decides the call.
- In real life: Dialling the number written on the note
- In React: The Effect connecting to
roomId - In real life: A new number on the note: hang up and dial again
- In React:
roomIdchanges: the cleanup, then the Effect - In real life: When they pick up, you say hello in whatever mood you're in now
- In React: The Effect Event reads the latest
themewhen the chat connects - In real life: Changing your mood doesn't make you redial
- In React:
themeisn't a dependency of the Effect
Logic in an Effect reacts: it runs again when a value it reads changes. An Effect Event holds logic that only reads values. Write it with useEffectEvent and call it from the Effect. It always sees the latest props and state, and it's not a dependency, so changing those values doesn't re-run the Effect.
Event handlers run on clicks, Effects run to stay in sync
You met the difference in Synchronizing with effects: an event handler runs because the person did something, and an Effect runs because the component is on screen. Here is a sharper way to see it. This chat connects with an Effect and sends with a handler. Type a message, click Send, then click Switch room.
import { useEffect, useState } from "react";import { createConnection, sendMessage } from "./chat.js"; function ChatRoom({ roomId }) { const [message, setMessage] = useState(""); useEffect(() => { const connection = createConnection(roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); function handleSend() { sendMessage(roomId, message); setMessage(""); } return ( <div> <input value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Type a message" /> <button onClick={handleSend}>Send</button> </div> );} 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> );}Both pieces of code read the same two values. The Effect on lines 7 to 11 reads roomId; handleSend on lines 13 to 16 reads roomId and message. Yet they behave very differently:
- Typing changes
messageand rendersChatRoomagain, but nothing is sent. Line 14 runs only when you click Send (line 21), and it reads whatevermessageholds at that moment:Sent to general: Hi. - Switching rooms changes
roomId, and React runs the Effect again on its own:Disconnected from general, thenConnecting to travel.... Nobody clicked anything inChatRoom; the new value was the reason.
How to read the table: each row asks one question, and the two columns answer it for a handler and for an Effect.
| Question | Event handler | Effect |
|---|---|---|
| What makes it run? | One interaction: a click, a key press, a submit. | Being on screen, and any change to a value it reads. |
| Does it read props and state? | Yes, from the render it belongs to. | Yes, and each one it reads is a dependency. |
| Does it run again when a value changes? | No. Only when the person does the same thing again. | Yes. React re-runs it when a dependency changes. |
| In the chat | handleSend sends the message when you click Send. | Connecting to roomId while the chat is on screen. |
Reactive values and reactive logic
React has a name for the values the two pieces of code read. Props, state, and any variable the component body works out from them are reactive values: they can be different on the next render. A value declared outside the component is the same on every render, so it isn't reactive.
const serverUrl = "https://chat.example.com";
function ChatRoom({ roomId, theme }) {
const [message, setMessage] = useState("");
const title = `Welcome to the ${roomId} room`;
// …
}Line 1 declares serverUrl outside ChatRoom: it never changes, so it isn't reactive. The props on line 3, roomId and theme, are reactive. So is the state on line 4, message, and title on line 5, because it's worked out from roomId during each render. This code fragment isn't a whole example, so it doesn't run.
serverUrl, outside the component, never changes. An event handler reads reactive values when it runs; an Effect runs again when one it reads changes.Code that reads reactive values is one of two kinds:
- Logic in an event handler isn't reactive. It reads the values when the person clicks, and it doesn't run again when they change. Typing a new
messageisn't a reason to send it. - Logic in an Effect is reactive. Every reactive value it reads is a dependency, and React re-runs it when one changes. A new
roomIdis exactly the reason to reconnect.
Now look at the overview's chat again. Line 9 sits inside the Effect, so it's reactive, and theme became a dependency of the whole connection. But from the person's side, showing the notification is more like an event: it happens once, at the moment the chat connects, and it should use whatever theme the page has then. That's non-reactive logic stuck inside reactive logic.
Move the non-reactive part into an Effect Event
useEffectEvent is a hook, added as a stable part of React in version 19.2. You pass it a function, and it gives you back an Effect Event: a function you call from inside your Effect, whose code reads the latest props and state without making them dependencies. Here is the chat with the notification moved into one. Click Dark theme again.
import { useEffect, useEffectEvent, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) { const onConnected = useEffectEvent(() => { showNotification(`Connected to ${roomId}`, theme); }); useEffect(() => { const connection = createConnection(roomId); connection.on("connected", () => { onConnected(); }); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [roomId, setRoomId] = useState("general"); const [isDark, setIsDark] = useState(false); return ( <div className={isDark ? "page dark" : "page"}> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button> <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} /> </div> );}Line 1 imports useEffectEvent from react. Lines 6 to 8 create the Effect Event, called onConnected, at the top level of ChatRoom, like any hook. Its code, line 7, is the notification, and it reads roomId and theme. Line 13, inside the Effect, calls onConnected() when the connection is ready. The Effect itself no longer reads theme, so line 17 lists only [roomId], and the linter agrees: Effect Events are never dependencies.
Now Dark theme logs nothing: no disconnect, no reconnect. Click Switch room, and then Light theme before 2 seconds pass. The room change reconnects, as it should, and when the chat connects the notification says Notification (light): Connected to travel: onConnected read the theme the page had at that moment, not when the Effect ran.
roomId, so the Effect runs. Changing the theme doesn't touch the array, so React skips the Effect. When the chat connects, onConnected() reads the theme the page has at that moment: dark.Compare the ways to write it in the playground. Pick a version, then switch rooms, change the theme, and press Wait 2 seconds to let the chat connect. Watch Effect runs and the theme of each notification.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection(roomId); connection.on("connected", () => { showNotification(`Connected to ${roomId}`, theme); }); connection.connect(); return () => connection.disconnect(); }, [roomId, theme]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [roomId, setRoomId] = useState("general"); const [isDark, setIsDark] = useState(false); return ( <div className={isDark ? "page dark" : "page"}> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button> <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} /> </div> );}Effect runs: 2Welcome to the general room. The page is light.
Chat: connecting to general
- Connecting to general...
- Disconnected from general
- Connecting to general...
ChatRoom appeared, so its Effect ran. In development, StrictMode ran the cleanup and the Effect once more. The chat is connecting: press Wait 2 seconds.
With theme in the array, every theme change adds two Effect runs. The middle version is what many people try first: leave theme out and silence the linter with a comment. The chat stops reconnecting, but try switching rooms and then changing the theme before you wait: the notification uses the old theme, marked in the playground. Only the Effect Event both stays connected and shows the right theme.
How to read the table: each row is one way to write the chat, what a theme change does, and which theme the notification uses.
| How the chat reads theme | When the theme changes | The notification's theme |
|---|---|---|
theme in the array | The chat disconnects and connects again. | The theme on the page. |
theme left out, with the linter silenced | Nothing happens. | The theme from the render the Effect ran in, which can be old. |
theme read inside an Effect Event | Nothing happens. | The theme on the page at the moment the chat connects. |
Effect Events read the latest props and state
Why is silencing the linter worse than an Effect Event? Each render creates its own Effect, as a closure over that render's props and state. If theme isn't a dependency, React keeps the Effect from an older render, and so does the callback on line 8. Here is that version, live. Click Switch room, then Dark theme within 2 seconds.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection(roomId); connection.on("connected", () => { showNotification(`Connected to ${roomId}`, theme); }); connection.connect(); return () => connection.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [roomId]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [roomId, setRoomId] = useState("general"); const [isDark, setIsDark] = useState(false); return ( <div className={isDark ? "page dark" : "page"}> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button> <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} /> </div> );}Line 13 tells the linter to stop checking line 14, so theme is missing from the array. Switching rooms ran the Effect while the page was light, and its callback on lines 8 to 10 keeps that render's theme. Changing to dark didn't run the Effect again, so the notification says Notification (light): Connected to travel on a dark page. Worse, the linter can no longer warn you when a later change adds a value the Effect really should react to.
An Effect Event doesn't keep old values. React updates the function behind it each time it commits a render, so a call always runs the latest version, with the latest props and state. That makes Effect Events useful well beyond themes. This product page logs a visit for each page you open, along with how many items are in the cart. Click Add to cart twice, then Cake.
import { useEffect, useEffectEvent, useState } from "react";import { logVisit } from "./analytics.js"; function ProductPage({ url, itemCount }) { const onVisit = useEffectEvent((visitedUrl) => { logVisit(visitedUrl, itemCount); }); useEffect(() => { onVisit(url); }, [url]); return <h3>Page {url}, {itemCount} in the cart</h3>;} export default function App() { const [url, setUrl] = useState("/tea"); const [itemCount, setItemCount] = useState(0); return ( <div> <button onClick={() => setUrl("/tea")}>Tea</button> <button onClick={() => setUrl("/cake")}>Cake</button> <button onClick={() => setItemCount(itemCount + 1)}>Add to cart</button> <ProductPage url={url} itemCount={itemCount} /> </div> );}The analytics.js tab logs each visit. The page first logs Visit /tea (cart: 0), twice in development. Adding to the cart logs nothing: a fuller cart isn't a new visit. Clicking Cake logs Visit /cake (cart: 2), with the latest count. The Effect on lines 9 to 11 reacts to url, and the Effect Event on lines 5 to 7 reads itemCount without reacting to it.
Notice that line 10 passes url to the Effect Event as an argument, visitedUrl, instead of letting it read url itself. The page's address is part of what happened: a visit to this page. Passing it keeps url in the Effect, where the linter makes sure it stays in the array. It also matters when the call comes later: an argument is the value from when the Effect ran, while a value read inside the Effect Event is the newest one. Exercise 6 shows the difference.
The rules for Effect Events
An Effect Event is a piece of one Effect's code that you've pulled out to the top of the component. That's why its rules are strict: only call it from inside Effects (or from another Effect Event) in the same component, and never pass it anywhere else. React itself enforces the first rule while rendering. Look at the result:
import { useEffectEvent, useState } from "react"; export default function App() { const [itemCount, setItemCount] = useState(0); const onVisit = useEffectEvent(() => { console.log(`Visit with ${itemCount} in the cart`); }); onVisit(); // A bug on purpose: calling it while rendering. return <button onClick={() => setItemCount(itemCount + 1)}>Add to cart</button>;}Line 9 calls onVisit() in the component body, so it runs during rendering, and React throws A function wrapped in useEffectEvent can't be called during rendering. Rendering must stay a pure calculation, as you saw in Keeping components pure; code that should run once the component is on screen goes in an Effect.
React doesn't throw for the other wrong places; the linter catches them. The rule is react-hooks/rules-of-hooks from eslint-plugin-react-hooks, version 6.1 or later (6.1 came out with React 19.2; the latest in October 2026 is 7.1.1). Older versions don't know about useEffectEvent. Here is why passing one to another component is wrong. App keeps a count and passes an Effect Event to a Ticker that adds to it every second. Watch the console for a few seconds.
import { useEffect, useEffectEvent, useState } from "react"; function Ticker({ onTick }) { useEffect(() => { console.log("Ticker started"); const id = setInterval(() => onTick(), 1000); return () => { clearInterval(id); console.log("Ticker stopped"); }; }, [onTick]); return null;} export default function App() { const [count, setCount] = useState(0); const [step, setStep] = useState(1); // Don't do this: an Effect Event passed to another component. const onTick = useEffectEvent(() => setCount(count + step)); return ( <div> <p>Count: {count}</p> <button onClick={() => setStep(step + 1)}>Step: {step}</button> <Ticker onTick={onTick} /> </div> );}Every second, the console prints Ticker stopped and Ticker started. useEffectEvent on line 20 returns a new function on every render, on purpose. Line 26 passes it to Ticker, whose Effect lists it on line 11. Each tick changes count, App renders again, onTick is a new function, and React restarts the timer. The count still climbs, but the ticker never runs for more than a second. The fix is to keep the Effect Event next to the Effect that calls it:
import { useEffect, useEffectEvent, useState } from "react"; function Ticker({ onTick }) { const onEachTick = useEffectEvent(onTick); useEffect(() => { console.log("Ticker started"); const id = setInterval(() => onEachTick(), 1000); return () => { clearInterval(id); console.log("Ticker stopped"); }; }, []); return null;} export default function App() { const [count, setCount] = useState(0); const [step, setStep] = useState(1); const onTick = () => setCount(count + step); return ( <div> <p>Count: {count}</p> <button onClick={() => setStep(step + 1)}>Step: {step}</button> <Ticker onTick={onTick} /> </div> );}Now App passes a plain function (line 21). Ticker wraps the prop in its own Effect Event on line 4, and its Effect on lines 6 to 13 calls onEachTick() and lists nothing, so the console prints Ticker started only when the ticker appears. Click Step a few times: the count jumps by the new step at the next tick, because the Effect Event reads the latest onTick, and with it the latest count and step.
The last rule is about intent. Don't use an Effect Event to hide a value the Effect should react to:
function ProductPage({ url }) {
const onVisit = useEffectEvent(() => {
logVisit(url);
});
useEffect(() => {
onVisit();
}, []);
// …
}Lines 2 to 4 read url inside the Effect Event, and line 8 gives the Effect an empty array. The linter is happy, but a new url is a new visit, and nothing logs it. The visit logger above passes url from an Effect with [url]. Ask what should cause the code to run again, and keep those values in the Effect.
How to read the table: each row is a place you might call an Effect Event, whether it's allowed, and what happens.
| Where you call it | Allowed? | What happens |
|---|---|---|
| Inside an Effect | Yes. | It runs with the latest props and state. |
| Inside another Effect Event in the same component | Yes. | The same: it reads the latest values. |
| While the component renders | No. | React throws A function wrapped in useEffectEvent can't be called during rendering. |
| In an event handler | No. | It runs, but the linter flags it. A handler already reads the values it needs; use a plain function. |
| As a prop for another component, or an argument for a hook | No. | The linter flags it. It's a new function on every render, so an Effect that lists it re-runs every time. |
| In a dependency array | No. | The linter flags it, and the Effect would re-run after every render. |
A support chat with all three kinds of code
A real component often uses all three. This support chat sends with a handler, stays connected with an Effect, and shows each reply with an Effect Event that reads the theme and the mute setting. Type Where is my tea?, press Send, and wait for the reply. Then click Mute and Dark theme, and send another.
import { useEffect, useEffectEvent, useState } from "react";import { createConnection, sendMessage } from "./chat.js";import { showNotification } from "./notifications.js"; function SupportChat({ roomId, theme, muted }) { const [text, setText] = useState(""); const onReply = useEffectEvent((reply) => { if (!muted) showNotification(reply, theme); }); useEffect(() => { const connection = createConnection(roomId); connection.on("message", (reply) => onReply(reply)); connection.connect(); return () => connection.disconnect(); }, [roomId]); function handleSend(e) { e.preventDefault(); sendMessage(roomId, text); setText(""); } return ( <form onSubmit={handleSend}> <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Type a message" /> <button>Send</button> </form> );} export default function App() { const [roomId, setRoomId] = useState("orders"); const [isDark, setIsDark] = useState(false); const [muted, setMuted] = useState(false); return ( <div className={isDark ? "page dark" : "page"}> <button onClick={() => setRoomId(roomId === "orders" ? "returns" : "orders")}>Room: {roomId}</button> <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button> <button onClick={() => setMuted(!muted)}>{muted ? "Unmute" : "Mute"}</button> <SupportChat roomId={roomId} theme={isDark ? "dark" : "light"} muted={muted} /> </div> );}handleSend on lines 19 to 23 sends the text, because the submit is the reason. The Effect on lines 12 to 17 keeps the connection on roomId. Line 14 tells the connection to call onReply for each reply, and the Effect Event on lines 8 to 10 decides what to show: nothing when muted, or a notification in the current theme. Muting and changing the theme log nothing, because neither is a dependency. Switching the room reconnects.
- Send the message when Send is clicked
- Place the order when Buy is clicked
- Connect to the room on screen
- Subscribe to price updates for the product shown
- Follow the window's width while the page is open
- Show the connected notification in the current theme
- Include the cart's item count when logging a visit
- Show a reply's notification, unless the chat is muted
Sort each job: is it caused by one interaction, should it re-sync when a value changes, or should it only read the latest values?
Common mix-ups
- "If the Effect re-runs too often, I'll leave the value out of the array." No. The Effect keeps an old value, and the linter can't help you any more. Move the logic that only reads the value into an Effect Event.
- "An Effect Event is just a stable version of my function." No. It's a new function on every render, on purpose. What stays up to date is the code it runs, which always sees the latest props and state.
- "Effect Events are for event handlers." No. They're called only from Effects. A click handler already reads the latest values; it doesn't need one.
- "I can pass an Effect Event to a child or a custom hook." No. Declare it next to the Effect that calls it, in the same component or hook, and pass plain functions around.
- "useEffectEvent lets me empty my dependency array." No. Values that should make the Effect run again stay in the Effect and in the array. Only logic that should not react moves out.
- "Logic in an Effect Event never sees new values." No. It's the other way round: it always sees the newest values. It just doesn't make the Effect run again when they change.
Practice exercises
Each exercise shows its result under the code, or when you press Edit & run, with the console under it. Change the code, and the result runs again as you type. Make a guess first, then use the hints.
In the Effect Event chat, the page is light. You click Switch room, then Dark theme right away. Which theme does the notification use 2 seconds later? Predict first, then try it in the result.
import { useEffect, useEffectEvent, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) { const onConnected = useEffectEvent(() => { showNotification(`Connected to ${roomId}`, theme); }); useEffect(() => { const connection = createConnection(roomId); connection.on("connected", () => { onConnected(); }); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [roomId, setRoomId] = useState("general"); const [isDark, setIsDark] = useState(false); return ( <div className={isDark ? "page dark" : "page"}> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button> <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} /> </div> );}import { useEffect, useEffectEvent, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) { const onConnected = useEffectEvent(() => { showNotification(`Connected to ${roomId}`, theme); }); useEffect(() => { const connection = createConnection(roomId); connection.on("connected", () => { onConnected(); }); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [roomId, setRoomId] = useState("general"); const [isDark, setIsDark] = useState(false); return ( <div className={isDark ? "page dark" : "page"}> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <button onClick={() => setIsDark(!isDark)}>{isDark ? "Light theme" : "Dark theme"}</button> <ChatRoom roomId={roomId} theme={isDark ? "dark" : "light"} /> </div> );}Switching rooms starts a new connection. A moment later you pick the dark theme, which doesn't re-run the Effect. When the chat connects, onConnected reads the latest theme, so it logs Notification (dark): Connected to travel.
Type Hi in the box and watch the console. How many Sent lines appear? Then make the chat send only when you click Send.
import { useEffect, useState } from "react";import { sendMessage } from "./chat.js"; export default function App() { const [message, setMessage] = useState(""); useEffect(() => { if (message !== "") { sendMessage("general", message); } }, [message]); return ( <div> <input value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Type a message" /> <button>Send</button> </div> );}import { useState } from "react";import { sendMessage } from "./chat.js"; export default function App() { const [message, setMessage] = useState(""); function handleSend() { sendMessage("general", message); setMessage(""); } return ( <div> <input value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Type a message" /> <button onClick={handleSend}>Send</button> </div> );}The Effect re-ran for every new message, so typing Hi sent H, then Hi: two lines. Sending isn't reactive. In handleSend, it runs once per click and reads message at that moment.
Wait for the chat to connect, then click Mute. What's the first line it logs? Then change the code so muting never reconnects.
import { useEffect, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, muted }) { useEffect(() => { const connection = createConnection(roomId); connection.on("connected", () => { if (!muted) showNotification(`Connected to ${roomId}`, "light"); }); connection.connect(); return () => connection.disconnect(); }, [roomId, muted]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [roomId, setRoomId] = useState("general"); const [muted, setMuted] = useState(false); return ( <div className="page"> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <button onClick={() => setMuted(!muted)}>{muted ? "Unmute" : "Mute"}</button> <ChatRoom roomId={roomId} muted={muted} /> </div> );}import { useEffect, useEffectEvent, useState } from "react";import { createConnection } from "./chat.js";import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, muted }) { const onConnected = useEffectEvent(() => { if (!muted) showNotification(`Connected to ${roomId}`, "light"); }); useEffect(() => { const connection = createConnection(roomId); connection.on("connected", () => { onConnected(); }); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h3>Welcome to the {roomId} room</h3>;} export default function App() { const [roomId, setRoomId] = useState("general"); const [muted, setMuted] = useState(false); return ( <div className="page"> <button onClick={() => setRoomId(roomId === "general" ? "travel" : "general")}>Switch room</button> <button onClick={() => setMuted(!muted)}>{muted ? "Unmute" : "Mute"}</button> <ChatRoom roomId={roomId} muted={muted} /> </div> );}With muted in the array, clicking Mute ran the cleanup, Disconnected from general, and connected again. In an Effect Event, muted is read when the chat connects, and it isn't a dependency, so Mute logs nothing.
Click Add to cart once. What does the console log? Then make visits log only when the page changes, still with the latest cart count.
import { useEffect, useState } from "react";import { logVisit } from "./analytics.js"; function ProductPage({ url, itemCount }) { useEffect(() => { logVisit(url, itemCount); }, [url, itemCount]); return <h3>Page {url}, {itemCount} in the cart</h3>;} export default function App() { const [url, setUrl] = useState("/tea"); const [itemCount, setItemCount] = useState(0); return ( <div> <button onClick={() => setUrl("/tea")}>Tea</button> <button onClick={() => setUrl("/cake")}>Cake</button> <button onClick={() => setItemCount(itemCount + 1)}>Add to cart</button> <ProductPage url={url} itemCount={itemCount} /> </div> );}import { useEffect, useEffectEvent, useState } from "react";import { logVisit } from "./analytics.js"; function ProductPage({ url, itemCount }) { const onVisit = useEffectEvent((visitedUrl) => { logVisit(visitedUrl, itemCount); }); useEffect(() => { onVisit(url); }, [url]); return <h3>Page {url}, {itemCount} in the cart</h3>;} export default function App() { const [url, setUrl] = useState("/tea"); const [itemCount, setItemCount] = useState(0); return ( <div> <button onClick={() => setUrl("/tea")}>Tea</button> <button onClick={() => setUrl("/cake")}>Cake</button> <button onClick={() => setItemCount(itemCount + 1)}>Add to cart</button> <ProductPage url={url} itemCount={itemCount} /> </div> );}With [url, itemCount], a new cart count re-ran the Effect, so one Add to cart logged Visit /tea (cart: 1), a visit that didn't happen. Reading itemCount in an Effect Event keeps the latest count without reacting to it.
Watch the console. Which two lines does it print every second? Then keep the timer running without restarting.
import { useEffect, useEffectEvent, useState } from "react"; function Ticker({ onTick }) { useEffect(() => { console.log("Ticker started"); const id = setInterval(() => onTick(), 1000); return () => { clearInterval(id); console.log("Ticker stopped"); }; }, [onTick]); return null;} export default function App() { const [count, setCount] = useState(0); const [step, setStep] = useState(1); // Don't do this: an Effect Event passed to another component. const onTick = useEffectEvent(() => setCount(count + step)); return ( <div> <p>Count: {count}</p> <button onClick={() => setStep(step + 1)}>Step: {step}</button> <Ticker onTick={onTick} /> </div> );}import { useEffect, useEffectEvent, useState } from "react"; function Ticker({ onTick }) { const onEachTick = useEffectEvent(onTick); useEffect(() => { console.log("Ticker started"); const id = setInterval(() => onEachTick(), 1000); return () => { clearInterval(id); console.log("Ticker stopped"); }; }, []); return null;} export default function App() { const [count, setCount] = useState(0); const [step, setStep] = useState(1); const onTick = () => setCount(count + step); return ( <div> <p>Count: {count}</p> <button onClick={() => setStep(step + 1)}>Step: {step}</button> <Ticker onTick={onTick} /> </div> );}Each tick renders App, which made a new Effect Event and passed it down, so Ticker's Effect logged Ticker stopped and Ticker started every second. Declared inside Ticker, the Effect Event isn't a dependency, and the timer keeps running.
Press Run again and click Cake at once. Wait 2 seconds. Which page does every line name? Then make each line name the page that was really visited.
import { useEffect, useEffectEvent, useState } from "react";import { logVisit } from "./analytics.js"; function ProductPage({ url, itemCount }) { const onVisit = useEffectEvent(() => { logVisit(url, itemCount); }); useEffect(() => { // Log the visit 2 seconds later, even if the person has moved on. setTimeout(() => onVisit(), 2000); }, [url]); return <h3>Page {url}</h3>;} export default function App() { const [url, setUrl] = useState("/tea"); return ( <div> <button onClick={() => setUrl("/tea")}>Tea</button> <button onClick={() => setUrl("/cake")}>Cake</button> <ProductPage url={url} itemCount={2} /> </div> );}import { useEffect, useEffectEvent, useState } from "react";import { logVisit } from "./analytics.js"; function ProductPage({ url, itemCount }) { const onVisit = useEffectEvent((visitedUrl) => { logVisit(visitedUrl, itemCount); }); useEffect(() => { // Log the visit 2 seconds later, even if the person has moved on. setTimeout(() => onVisit(url), 2000); }, [url]); return <h3>Page {url}</h3>;} export default function App() { const [url, setUrl] = useState("/tea"); return ( <div> <button onClick={() => setUrl("/tea")}>Tea</button> <button onClick={() => setUrl("/cake")}>Cake</button> <ProductPage url={url} itemCount={2} /> </div> );}Read inside the Effect Event, url is the newest value when the timer fires, so all three lines said /cake. Passed as an argument, it's the url of the render whose Effect started the timer: /tea twice in development, then /cake.
Check your understanding
Question 1 of 7Which values in this component are reactive?
Read the code, then predictJSXconst serverUrl = "https://chat.example.com"; function ChatRoom({ roomId }) { const [message, setMessage] = useState(""); const title = `Welcome to the ${roomId} room`; // … }Choose an answer to see the explanation.
Question 2 of 7The chat is connected to general. You click Dark theme. What does the console print?
Read the code, then predictJSXimport { useEffect, useEffectEvent, useState } from "react"; import { createConnection } from "./chat.js"; import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) { const onConnected = useEffectEvent(() => { showNotification(`Connected to ${roomId}`, theme); }); useEffect(() => { const connection = createConnection(roomId); connection.on("connected", () => { onConnected(); }); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h3>Welcome to the {roomId} room</h3>; }Choose an answer to see the explanation.
Question 3 of 7Where may you call an Effect Event?
Choose an answer to see the explanation.
Question 4 of 7What happens when this component renders?
Read the code, then predictimport { useEffectEvent, useState } from "react"; export default function App() { const [itemCount, setItemCount] = useState(0); const onVisit = useEffectEvent(() => { console.log(`Visit with ${itemCount} in the cart`); }); onVisit(); // A bug on purpose: calling it while rendering. return <button onClick={() => setItemCount(itemCount + 1)}>Add to cart</button>; }Choose an answer to see the explanation.
Question 5 of 7What goes wrong when you list an Effect Event in the dependency array, like this?
Read the code, then predictJSXconst onConnected = useEffectEvent(() => { showNotification(`Connected to ${roomId}`, theme); }); useEffect(() => { // … }, [roomId, onConnected]);Choose an answer to see the explanation.
Question 6 of 7This page logs a visit when it appears. You click Cake. What does the console add?
Read the code, then predictimport { useEffect, useEffectEvent, useState } from "react"; function ProductPage({ url }) { const onVisit = useEffectEvent(() => { console.log("Visit", url); }); useEffect(() => { onVisit(); }, []); return <h3>Page {url}</h3>; } export default function App() { const [url, setUrl] = useState("/tea"); return ( <div> <button onClick={() => setUrl("/cake")}>Cake</button> <ProductPage url={url} /> </div> ); }Choose an answer to see the explanation.
Question 7 of 7In this chat the linter was silenced, and the page is dark. You click Switch room, then Light theme a second later. Which theme does the notification use?
Read the code, then predictJSXimport { useEffect, useState } from "react"; import { createConnection } from "./chat.js"; import { showNotification } from "./notifications.js"; function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection(roomId); connection.on("connected", () => { showNotification(`Connected to ${roomId}`, theme); }); connection.connect(); return () => connection.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [roomId]); return <h3>Welcome to the {roomId} room</h3>; }Choose an answer to see the explanation.
Key takeaways
- Event handlers run because of one interaction. Effects run, and run again, to stay in sync with the values they read.
- Props, state, and values worked out from them during render are reactive. Every one an Effect reads is a dependency.
- Logic that should only read a value, not react to it, goes in an Effect Event:
useEffectEvent(fn), part of React since 19.2. - An Effect Event always sees the latest props and state, and it's never a dependency.
- Call it only from Effects in the same component. Calling it while rendering throws; handlers, props, and arrays are flagged by the linter.
- Pass the parts of the event as arguments, and never use an Effect Event to hide a real dependency.
An Effect reacts; an Effect Event only reads.
Keep what should re-sync in the Effect, and move what should only see the latest values into useEffectEvent.
Coming next: Removing effect dependencies. It collects every way to make an Effect re-run less often by changing its code, Effect Events included.