MENU
Effects
Rendering must be pure: a component reads props and state and returns JSX, and nothing else. Real applications still have to talk to the world outside React â a WebSocket, a <video> element, localStorage, an analytics script, an HTTP API. An effect is the escape hatch for that work. It is a function React runs after it has committed your JSX to the DOM, so by the time it runs the nodes it touches already exist.The mental model that causes the most bugs is the one inherited from class components, where componentDidMount and componentDidUpdate were things that happened at a point in time. An effect is not an event. It describes how to keep an external system synchronized with the current props and state, plus how to undo that synchronization â and React decides when to run each half.
Figure 2.14: Where useEffect and useLayoutEffect run in one
updateEverything here follows from that picture: the dependency array
decides when React re-synchronizes, the cleanup decides
how it un-synchronizes, and the two ways that pair fails are
running too often and running with stale values.2.4.1 What an Effect Is and When You Need One
useEffect(setup, dependencies) takes a setup function and an optional dependency array. React calls the setup function after the commit that follows a render, never during the render itself. Mutations, network requests, subscriptions, timers and logging all belong there, because none of them may happen while your component is computing its JSX.import { useState, useEffect } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
}); // no array: runs after every render
return <button onClick={() => setCount(count + 1)}>Clicked {count} times</button>;
}
2.4.2 Dependencies
The second argument decides how often React re-synchronizes. React compares each item with its value from the previous render using Object.is; if every item is equal, the setup function is skipped.function ChatRoom({ roomId, theme }) { // theme is not read below
const [messages, setMessages] = useState([]);
useEffect(() => {
const conn = createConnection(roomId);
conn.on('message', m => setMessages(prev => [...prev, m]));
conn.connect();
return () => conn.disconnect();
}, [roomId]); // setMessages is stable, so it is not listed
return <MessageList items={messages} />;
}
2.4.3 Cleanup
If the setup function returns a function, React treats it as the cleanup. It runs before every re-run of the setup, and once more when the component unmounts. Connections are closed, timers cleared, listeners removed â whatever the setup started.In development, React mounts every component, immediately unmounts it, and mounts it again. Effects therefore run setup, cleanup, setup. That is not a bug to work around; it is a test you get for free, and a missing cleanup shows up immediately as a duplicated connection or a doubled counter. Production runs the setup once.<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">
{ "imports": { "react": "https://esm.sh/react@19.3.0?dev",
"react-dom/client": "https://esm.sh/react-dom@19.3.0/client?dev" } }
</script>
<script type="text/babel" data-type="module">
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
const log = [];
function ChatRoom({ roomId }) {
const [, redraw] = useState(0);
useEffect(() => {
log.push('connect #' + roomId);
redraw(n => n + 1); // demo scaffolding: repaint the log below
return () => log.push('disconnect #' + roomId);
}, [roomId]);
return <pre style={{fontSize: '15px'}}>{log.join('\n')}</pre>;
}
function App() {
const [room, setRoom] = useState('general');
return <><button onClick={() => setRoom('random')}>Switch to #random</button>
<ChatRoom roomId={room} /></>;
}
createRoot(document.getElementById('root')).render(
<React.StrictMode><App /></React.StrictMode>);
setTimeout(() => document.querySelector('button').click(), 800); // simulate a click
</script>
Figure 2.15: Browser output of Listing 2.24The first three lines of the output are the development double-mount.
The last two are the real point of the cleanup: when roomId changes,
React does not "update" the effect â it tears the old synchronization
down and sets a new one up.2.4.4 Fetching Data in an Effect
A request is a side effect with a lifetime, so it fits the setup-and-cleanup shape: start the request in the setup, abort it in the cleanup. Because the setup itself cannot be async, declare the async work inside and call it.function Product({ id }) {
const [state, setState] = useState({ status: 'loading' });
useEffect(() => {
const ctrl = new AbortController();
setState({ status: 'loading' });
(async () => {
try {
const res = await fetch(`https://api.example.com/products/${id}`,
{ signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setState({ status: 'ready', product: await res.json() });
} catch (err) {
if (err.name !== 'AbortError') setState({ status: 'error', err });
}
})();
return () => ctrl.abort(); // the user navigated away or id changed
}, [id]);
if (state.status === 'loading') return <p>Loading...</p>;
if (state.status === 'error') return <p role="alert">{state.err.message}</p>;
return <h2>{state.product.name}</h2>;
}
2.4.5 Out-of-Order Responses
Network responses do not come back in the order you asked for them. Click product #1, then quickly click product #2, and the slow first response can land after the fast second one â leaving the details of #1 on screen under the heading of #2. Nothing in fetch prevents this, and it is the single most common data bug in React applications.
Figure 2.16: A slow first response overwriting a newer one, and the
flag that stops itThe fix is a variable local to each run of the effect. The cleanup
for the run that fetched #1 flips that variable before the run for #2
starts, so when #1 finally resolves it knows it is no longer
current.useEffect(() => {
let ignore = false;
async function load() {
const res = await fetch(`https://api.example.com/products/${id}`);
const json = await res.json();
if (!ignore) setProduct(json); // still the current effect for this id
}
load();
return () => { ignore = true; };
}, [id]);
2.4.6 Avoiding Unnecessary Firing
An effect that re-runs whenever an unrelated value changes is usually reading something it does not need. A timer is the clearest case: setCount(count + 1) needs count, so count must be a dependency, so React clears and recreates the interval on every tick. The updater form needs no dependency at all.function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('subscribing'); // logged once, not once per second
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []); // reads no reactive value
return <h1>{count}</h1>;
}
Use an updater function when the effect only needs the previous state, as above.
Move a function inside the effect. A function declared in the component body is a new value on every render, so listing it re-runs the effect every render. Declared inside, it is not a dependency.
Move a constant out of the component. An object or array literal in the body (const opts = {a: 1}) also changes identity every render; hoist it to module scope, or build it inside the effect.
2.4.7 Separating Events from Effects
Some code inside an effect is reactive and some is not. A chat component that connects to roomId and shows a notification styled with the current theme reads two values, but only one should cause a reconnection. List theme and switching to dark mode drops the connection; leave it out and the notification uses whatever theme was current when the effect last ran.useEffectEvent, added in React 19.2, marks the non-reactive part. The function it returns always sees the latest props and state, yet never counts as a dependency.import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme); // always the latest theme
});
useEffect(() => {
const conn = createConnection(roomId);
conn.on('connected', () => onConnected());
conn.connect();
return () => conn.disconnect();
}, [roomId]); // theme deliberately absent
return <Messages roomId={roomId} />;
}
Declare it at the top level of a component or custom Hook, never in a loop or condition.
Call it only from effects in the same component, never during render or from an event handler.
Never pass it to another component or Hook, and never list it as a dependency.
2.4.8 useLayoutEffect
useLayoutEffect has the identical signature to useEffect and one difference in timing: React runs it after the DOM has been mutated but before the browser paints, and any state update inside it is flushed in the same frame. The user never sees the intermediate layout.That matters when a component has to measure something it just rendered and then correct itself â a tooltip that must sit above its target, a list that scrolls to the bottom, a box sized to its content. With useEffect, the first layout is painted, then the corrected one, and the element visibly jumps.<div id="root"></div>
<style>.tip{position:absolute;background:#37474f;color:#fff;padding:4px 8px}</style>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">
{ "imports": { "react": "https://esm.sh/react@19.3.0?dev",
"react-dom/client": "https://esm.sh/react-dom@19.3.0/client?dev" } }
</script>
<script type="text/babel" data-type="module">
import React, { useRef, useState, useLayoutEffect } from 'react';
import { createRoot } from 'react-dom/client';
function Tooltip({ rect, children }) {
const ref = useRef(null);
const [top, setTop] = useState(0);
useLayoutEffect(() => {
const h = ref.current.getBoundingClientRect().height;
setTop(rect.top - h - 6); // measured, re-rendered, then painted once
}, [rect]);
return <div className="tip" ref={ref} style={{top, left: rect.left}}>{children}</div>;
}
function App() {
const btn = useRef(null), [rect, setRect] = useState(null);
return <div style={{padding: '60px 16px'}}>
<button ref={btn} onClick={() => setRect(btn.current.getBoundingClientRect())}>
Show tooltip</button>
{rect && <Tooltip rect={rect}>Placed above, never seen at top 0</Tooltip>}
</div>;
}
createRoot(document.getElementById('root')).render(<App />);
setTimeout(() => document.querySelector('button').click(), 400); // simulate a click
</script>
Figure 2.17: Browser output of Listing 2.29The cost is real: layout effects block painting, so a slow one
freezes the frame, and they cannot run during server rendering, which
makes React warn. Reach for useEffect first and switch only when a
measurement has to happen before the user sees the result.
useInsertionEffect, which runs even earlier, exists for CSS-in-JS
libraries injecting <style> rules and is not meant for application
code.2.4.9 Effects You Do Not Need
Most effects in a struggling codebase synchronize with nothing external. They are ordinary logic pushed into a callback that runs one render too late, costing an extra render pass and a moment in which the screen shows inconsistent values.// unnecessary: an extra render pass, and fullName is stale for one paint
const [fullName, setFullName] = useState('');
useEffect(() => { setFullName(first + ' ' + last); }, [first, last]);
// correct: one render, and the two can never disagree
const fullName = first + ' ' + last;