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>;

}

Listing 2.22: Synchronizing the document title with state document.title is not part of the React tree, so React cannot set it for you; the effect keeps it in step with count. That is the test to apply before reaching for useEffect: is there something outside React that has to be told about this state? If the answer is no — if you are only deriving a value, reacting to a click, or resetting state — an effect is the wrong tool, and Section 2.4.9 lists the alternatives. Warning: Never pass an async function to useEffect. React expects the return value to be a cleanup function, and an async function returns a promise. Declare the async function inside the effect and call it, as in Section 2.4.5.

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. Second argument When the setup function runs omitted After every render, including every state update [roomId] or [a, b] After the first render, and after any render where a listed value changed [] Once when the component mounts (twice in development, see Section 2.4.3) Table 2.8: How the dependency array controls an effect The array is not a choice — it is a fact about the code. Every reactive value the setup function reads (props, state, and anything computed from them during render) must appear in it. Omitting one does not "run the effect less"; it runs the effect with values frozen from an earlier render.

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} />;

}

Listing 2.23: A dependency array that matches what the effect reads Let a linter check this instead of your memory. npm i -D eslint-plugin-react-hooks@7.1.1 enables the react-hooks/exhaustive-deps rule, which reports the missing or superfluous entry and offers a fix.

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>

Listing 2.24: A chat connection that logs every setup and cleanup 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. Tip: The same shape covers every subscription. window.addEventListener('keydown', onKey) in the setup and window.removeEventListener('keydown', onKey) in the cleanup, with the same function reference in both, is the standard way to listen for keyboard input from a component.

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>;

}

Listing 2.25: Loading a product, with loading, error and abort handling Three states — loading, error, ready — are the minimum. A component that tracks only data shows a blank screen on failure and cannot tell "no results" from "not finished".The pattern is correct but limited. The request starts only after the component renders, so a parent and child that both fetch create a waterfall; nothing is cached, so going back refetches; and two components asking for the same thing duplicate the work. Production applications move fetching elsewhere: a route loader (Section 2.17.3), a Next.js server component (Chapter 5), or a cache library such as TanStack Query 5.103.1 (npm i @tanstack/react-query), which keeps these mechanics and adds deduplication, caching, retries and revalidation. Chapter 3 builds the Express endpoints these examples call.

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]);

Listing 2.26: Ignoring a response that arrived too late Aborting the request (Section 2.4.4) and the ignore flag solve different halves of the problem. abort() stops work that is still in flight; the flag also covers a response that already resolved and is sitting in the microtask queue, and it works for any promise, not just fetch. Use both.

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>;

}

Listing 2.27: One interval for the lifetime of the component subscribing Output of Listing 2.27 Three moves remove most spurious dependencies:
  • 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.

  • Deleting a dependency the linter asked for never works: the effect then fires with values from an old render, and the bug moves from "too often" to "silently wrong". Section 2.4.7 has the supported way to say that a value is not reactive.

    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} />;

    }

    Listing 2.28: Reconnecting only when the room changes The rules are narrow on purpose, because an effect event is a hole in the reactive model:
  • 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.

  • Read the split out loud: "connect to the room, and when that happens, notify with the current theme." Before the comma is the effect; after it is the event.

    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>

    Listing 2.29: A tooltip that measures itself before the browser paints 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;

    Listing 2.30: Derived state belongs in the render, not in an effect An Effect that... What to do instead Computes a value from props or state Calculate it during rendering Runs because the user clicked or submitted Put the logic in the event handler Caches an expensive calculation useMemo (Section 2.7.3) Resets all state when a prop changes Pass that prop as key (Section 2.3.7) Updates state that another Effect then reads Compute both in one event handler Notifies a parent after a state change Call the parent's callback in the handler Subscribes to an external store useSyncExternalStore (Section 2.8.8) Table 2.9: Common effects that should not be effects One question separates the two groups. If a user did something, the code belongs in an event handler; if the component being on screen is what requires the work, it belongs in an effect. "Send an analytics event when the Buy button is clicked" is a handler. "Keep the analytics session open while this page is visible" is an effect. Under the hood: Each useEffect call adds a node to the fiber's effect list and a closure that captures that render's props and state. React runs the list after the commit, in the order the Hooks were called. An effect you did not need is not free — it is a retained closure and a scheduled callback for every render.