React NotesRohit’s interview study guide
Chapter 05

Effects

useEffect from the ground up: when effects run, dependency arrays, cleanup, infinite loops, async code and race conditions, Strict Mode's double run, useLayoutEffect, useEffectEvent, and when not to use an effect at all.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#useEffect

Rendering in React must be pure: a component takes props and state and returns JSX, nothing else. But real apps have to talk to things outside React — set document.title, open a WebSocket, start a timer, subscribe to a store, call a non-React widget. useEffect is the place for that code. React runs it after it has rendered and committed the result to the screen, so the effect sees the up-to-date DOM and doesn't slow down the render itself.

The mental model the React docs push, and the one that prevents most effect bugs: an effect is not "code that runs on mount"; it's a synchronisation. You describe "keep this external thing in sync with these values" (the setup), and "here's how to stop" (the cleanup). React runs setup after the first render, runs cleanup + setup again whenever the values change, and runs cleanup when the component goes away.

From my notes, the shape is:

React
useEffect(() => {
  // Your side effect logic here

  return () => {
    // Cleanup logic here (optional)
  };
}, [dependencies]);

My notes listed three "parameters": the effect function, the cleanup function and the dependency array. Correcting my notes: useEffect takes two arguments — the setup function and the optional dependency array. The cleanup isn't a parameter; it's the return value of the setup function. The descriptions themselves were right: the cleanup runs before the effect runs again and when the component unmounts, and the dependency array tells React when to re-run.

TitleCounter.jsx
import { useEffect, useState } from "react";

function TitleCounter() {
  const [count, setCount] = useState(0);
  console.log(`render ${count}`);

  useEffect(() => {
    document.title = `Clicked ${count} times`;
    console.log(`effect ${count} (title is now "${document.title}")`);
  }, [count]);

  return <button onClick={() => setCount(count + 1)}>Clicked {count}</button>;
}
// render 0
// effect 0 (title is now "Clicked 0 times")
// click:
// render 1
// effect 1 (title is now "Clicked 1 times")
What’s happening
  1. First render: the function body runs and logs render 0. Calling useEffect here does not run the effect — it only hands React a function to run later, together with the dependency array [0].
  2. React commits the <button> to the DOM. Then, after the browser has had a chance to paint, it runs the effect: the title becomes Clicked 0 times and the effect logs it. Render always comes first, effect second.
  3. A click calls setCount(1). React re-renders: render 1. This render creates a new effect function — a closure over count = 1 — and new dependencies [1].
  4. React compares [1] with the previous [0]; they differ, so after committing it runs the new effect: effect 1.
  5. The effect reads count from its own render, like every function created during rendering. That's why the title is never stale: each render's effect sees that render's values.

Where effects belong in real code: subscriptions (WebSocket, EventSource, browser events, a store), timers, integrating non-React code (a map library, a chart, focus management), and analytics that should fire because a component is shown. Where they don't belong: computing values from props or state, and reacting to user events — see You might not need an effect.

#The dependency array

The second argument tells React which values the effect uses, so React can skip re-running it when none of them changed. There are three forms, and interviewers like to ask for all three:

  • No array — the effect runs after every render.
  • [] — the effect runs after the first render only (and cleans up on unmount).
  • [a, b] — the effect runs after the first render and after any render where a or b changed.

"Changed" is decided with Object.is, the same check useState uses: primitives compare by value, objects and functions by reference. Think of the array as the effect's shopping list: React only goes back to the shop when something on the list is different — and an object created during render is a "different" item every single time, even if it holds the same data.

DepsDemo.jsx
function DepsDemo({ a, b }) {
  useEffect(() => { console.log("no array: every render"); });
  useEffect(() => { console.log("[]: mount only"); }, []);
  useEffect(() => { console.log(`[a]: a is ${a}`); }, [a]);
  return null;
}

// render(<DepsDemo a={1} b={1} />)
// no array: every render
// []: mount only
// [a]: a is 1
// --- rerender with b=2
// no array: every render
// --- rerender with a=2
// no array: every render
// [a]: a is 2
What’s happening
  1. First render: all three effects run, in the order they were declared. The first render always runs every effect, whatever the array says.
  2. Re-render with b={2}: the no-array effect runs (it runs after every render). The [] effect compares an empty list with an empty list — nothing changed — and is skipped. The [a] effect compares [1] with [1] and is skipped. b isn't in any list, so its change triggers nothing.
  3. Re-render with a={2}: the no-array effect runs again, [] is skipped again, and [a] sees Object.is(1, 2) is false, so it runs and logs a is 2.
  4. The array isn't a "watch list" you choose freely: it must contain every reactive value the effect reads (props, state, and anything computed from them). The react-hooks/exhaustive-deps ESLint rule checks this for you.

The reference trap:

ObjectDeps.jsx
function ObjDep({ id }) {
  const options = { id }; // a new object on every render
  useEffect(() => {
    console.log(`effect with options.id=${options.id}`);
  }, [options]);
  return null;
}
// mount + 2 re-renders with the same id → the effect runs 3 times

function ObjDepFixed({ id }) {
  useEffect(() => {
    const options = { id }; // created inside the effect
    console.log(`effect with options.id=${options.id}`);
  }, [id]);
  return null;
}
// mount + 2 re-renders with the same id → the effect runs once
What’s happening
  1. In ObjDep, const options = { id } runs on every render and creates a brand-new object. Its contents are identical ({ id: 1 }) each time.
  2. React compares the previous options with the new one using Object.is — two different objects are never equal — so the effect re-runs after every render. The test logged it three times for three renders with the same id.
  3. If the effect also set state, this would become an infinite loop (see Infinite loops in effects).
  4. ObjDepFixed moves the object inside the effect and depends on the primitive id instead. Object.is(1, 1) is true, so the effect runs once.
  5. The general fixes, in order of preference: create the object or function inside the effect; depend on primitives; move constants outside the component; and only then reach for useMemo/useCallback to keep a stable reference.
How many times does the effect run?
React
function Search({ query }) {
  const filters = ["active"];
  useEffect(() => {
    console.log("fetch", query, filters);
  }, [query, filters]);
  return null;
}
// rendered with query="a", then re-rendered with query="a", then with query="b"
Show answer

Three times — once per render.

What’s happening
  1. First render: every effect runs. Log 1.
  2. Second render with the same query: query is equal, but filters is a new array literal on every render, so Object.is(oldFilters, newFilters) is false and the effect runs. Log 2.
  3. Third render: both query and filters differ. Log 3.
  4. Fix: move const filters = ["active"] outside the component (it never changes), or inside the effect. Then it runs on the first render and when query becomes "b" — twice.

#Cleanup functions

If your effect starts something — a subscription, a timer, an event listener, a connection, a request — it should return a function that stops it. React calls that cleanup at two moments: before re-running the effect (with the old values), and when the component unmounts.

The mental model: every setup has a matching "undo", and React always pairs them up. For any run of the effect, React will eventually call that same run's cleanup, which closed over that run's values. So the cleanup for roomId = "general" disconnects "general", even if roomId is already "travel" by the time it runs.

ChatRoom.jsx
function ChatRoom({ roomId }) {
  useEffect(() => {
    console.log(`connect ${roomId}`);
    return () => console.log(`disconnect ${roomId}`);
  }, [roomId]);
  return <h1>{roomId}</h1>;
}

// render(<ChatRoom roomId="general" />), rerender with "travel", unmount
// connect general
// disconnect general
// connect travel
// disconnect travel
What’s happening
  1. Mount with "general": the setup runs and logs connect general. React keeps the returned cleanup — a closure over roomId = "general".
  2. Re-render with "travel": the dependency changed, so before running the new setup, React calls the stored cleanup from the previous run: disconnect general. That's why it says general and not travel.
  3. Then the new setup runs: connect travel, and React stores the new cleanup (closed over "travel").
  4. Unmount: React calls the last stored cleanup: disconnect travel. Every connect is paired with exactly one disconnect, in order.

My notes' interval example, the most common cleanup interview question:

Clock.jsx
function Clock() {
  const [seconds, setSeconds] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setSeconds((s) => s + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
  return <p>{seconds}s</p>;
}
// after 3s: "3s"; after unmount: 0 timers left

function LeakyClock() {
  const [seconds, setSeconds] = useState(0);
  useEffect(() => {
    setInterval(() => {
      console.log("tick");
      setSeconds((s) => s + 1);
    }, 1000);
    // ❌ no cleanup
  }, []);
  return <p>{seconds}s</p>;
}
// unmount, then wait 2s: "tick", "tick" — and 1 timer still alive
What’s happening
  1. Clock starts an interval once ([]) and keeps its id in the effect's closure. Using the updater s => s + 1 means the effect doesn't read seconds, so [] is honest.
  2. The test used fake timers: advancing 3000 ms showed 3s. After unmount(), the cleanup called clearInterval(id), and vi.getTimerCount() reported 0.
  3. LeakyClock never clears its interval. After unmount the component is gone, but the browser keeps calling the callback: the test still logged tick twice over the next 2 seconds and reported 1 timer still alive.
  4. That callback also keeps its closure (and anything it references) in memory — that's the real memory leak. The setSeconds calls on an unmounted component simply do nothing (React 18 removed the old warning about them, see Race conditions and AbortController).
AdvancedWhen exactly does cleanup run?
  • Cleanup for a re-run happens after the next render is committed, just before the new setup — not before the render. So during the next render, the old subscription is still active.
  • On unmount, React runs all of a component's effect cleanups (layout effect cleanups first, then passive effect cleanups — see useLayoutEffect for the logged order).
  • Cleanup closes over the values of its render. If you need the latest value in a cleanup, you're probably modelling it wrong; if you really do, read it from a ref.
  • Since React 17, passive effect cleanups run asynchronously after the commit (like the effects themselves), so a cleanup can't block the screen from updating.

#Infinite loops in effects

From my notes: an effect that updates a state variable that's also in its dependency array re-runs every time it updates that state — so it updates it again, forever. The cycle is: render → effect → setState → render → effect → … Every turn is a full render and commit.

InfiniteLoop.jsx
function InfiniteLoopComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // This will cause an infinite loop
    setCount(count + 1); // Incrementing count
  }, [count]); // count is a dependency

  return <div>Count: {count}</div>;
}
// console.error (React 19.3):
// Maximum update depth exceeded. This can happen when a component calls setState inside
// useEffect, but useEffect either doesn't have a dependency array, or one of the
// dependencies changes on every render.
What’s happening
  1. Render 1: count is 0. After commit the effect runs and calls setCount(1).
  2. count changed, so React re-renders. Render 2 produces deps [1], which differ from [0], so the effect runs again and calls setCount(2).
  3. This repeats forever. The test put a safety valve in the effect (stop after 200 renders) and saw the counter reach Count: 200, with React logging the "Maximum update depth exceeded" message above three times along the way.
  4. Note that it's only a console.error — for effects React warns and keeps going, so in a browser the tab spins at 100% CPU. Setting state directly during render is different: React throws "Too many re-renders. React limits the number of renders to prevent an infinite loop." (also tested).
  5. Leaving the array out entirely (useEffect(() => setCount(c => c + 1))) loops the same way, because a no-array effect runs after every render.

How to fix it depends on what you actually wanted:

React
// 1. "Increment once on mount": no dependency on count needed
useEffect(() => {
  setCount((c) => c + 1);
}, []);
// renders twice in total (initial + one update), shows "Count: 1"

// 2. "Update only until a condition holds": guard the setState
useEffect(() => {
  if (count < 3) setCount(count + 1);
}, [count]);

// 3. "Keep b in sync with a": don't use state + effect at all — compute it
const doubled = count * 2;
What’s happening
  1. Fix 1 uses the updater form, so the effect doesn't read count and [] is an honest dependency array. The test showed exactly two renders and Count: 1.
  2. Fix 2 keeps the dependency but adds a condition that eventually becomes false: 0 → 1 → 2 → 3, then the effect runs once more, does nothing, and the loop ends.
  3. Fix 3 is the most common real answer: if a value can be computed from props or state, compute it during render. No state, no effect, no loop (more in You might not need an effect).
  4. The other loop source is a dependency that changes every render — an object, array or function created in the component body — combined with a setState in the effect (for example, a fetch whose options object is a dependency). Fix it as in The dependency array.

#Async code in effects

You can't pass an async function to useEffect. An async function always returns a promise, but React expects the effect to return either nothing or a cleanup function. My notes marked this "NOW REALLY IMPORTANT" and had an "Incorrect way" and a "Correct way" — but both were screenshots that didn't survive the export, so the code below is the standard pair, not a copy of them.

ProfileWrong.jsx
function ProfileWrong({ userId }) {
  const [user, setUser] = useState(null);

  // ❌ the effect returns a Promise
  useEffect(async () => {
    const data = await fetchUser(userId);
    setUser(data);
  }, [userId]);

  return <p>{user ? user.name : "Loading…"}</p>;
}
// console.error (React 19.3):
// useEffect must not return anything besides a function, which is used for clean-up.
// It looks like you wrote useEffect(async () => ...) or returned a Promise. Instead, write the
// async function inside your effect and call it immediately: …
// …and later, on unmount: TypeError: destroy is not a function
What’s happening
  1. useEffect(async () => …) runs the async function after commit. It returns a promise immediately (before fetchUser resolves), and React stores that promise as if it were the cleanup.
  2. In development React spots this and logs the error quoted above — the test captured it word for word, followed by a code sample showing the fix and a link to react.dev.
  3. The fetch still works, so the name does appear — which is why this bug survives code review. But there's no way to cancel or ignore a stale response, because you can't return a real cleanup.
  4. When React later tries to run "the cleanup" (on unmount or before re-running), it calls the promise as a function. In the test, unmount() threw TypeError: destroy is not a function (destroy is React's internal name for the cleanup).
  5. TypeScript catches it at compile time: TS2345: Argument of type '() => Promise<void>' is not assignable to parameter of type 'EffectCallback'. Type 'Promise<void>' is not assignable to type 'void | Destructor'.

The correct way: define the async function inside the effect, call it, and return a normal cleanup:

ProfileRight.jsx
function ProfileRight({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let ignore = false;

    async function load() {
      const data = await fetchUser(userId);
      if (!ignore) setUser(data);
    }
    load();

    return () => {
      ignore = true;
    };
  }, [userId]);

  return <p>{user ? user.name : "Loading…"}</p>;
}
What’s happening
  1. The effect function itself is a normal (non-async) function, so it returns exactly what React expects: a cleanup.
  2. load() is called and runs up to await fetchUser(userId), then the effect returns. The fetch continues in the background.
  3. let ignore = false lives in this run's closure. The cleanup flips it to true when userId changes or the component unmounts.
  4. When the response arrives, if (!ignore) decides whether this response still matters. A response for an old userId is ignored — which is the fix for the race condition in the next topic.
  5. Error handling goes inside load with try/catch; an unhandled rejection inside the effect is not caught by error boundaries.

#Race conditions and AbortController

When an effect fetches data based on a prop, responses can come back out of order. Type "re", then "react": two requests go out, and if the slow "re" response arrives after the fast "react" one, a naive effect shows results for "re" while the input says "react". That's a race condition, and it's the main reason an effect that fetches needs a cleanup.

Two fixes, both driven by the cleanup: ignore stale responses with a flag (the ignore variable from the previous topic), or cancel stale requests with an AbortController. A useful picture: every effect run is a ticket; the cleanup voids the old ticket, so when an old response comes back, nobody's waiting for it.

Search.jsx
function SearchNaive({ query }) {
  const [results, setResults] = useState([]);
  useEffect(() => {
    fetch(`/api/search?q=${query}`)
      .then((res) => res.json())
      .then((data) => setResults(data)); // ❌ whichever arrives last wins
  }, [query]);
  return <p>{results.join(", ")}</p>;
}

function SearchAbort({ query }) {
  const [results, setResults] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      try {
        const res = await fetch(`/api/search?q=${query}`, { signal: controller.signal });
        setResults(await res.json());
      } catch (err) {
        if (err.name !== "AbortError") setError(err); // aborting isn't an error
      }
    }
    load();

    return () => controller.abort();
  }, [query]);

  if (error) return <p>Error: {error.message}</p>;
  return <p>{results.join(", ")}</p>;
}

// query "re" → "react"; the "react" response arrives first, the "re" response last
// SearchNaive:  shows "result for react", then flips to "result for re"   ❌
// SearchIgnore: shows "result for react" and stays
// SearchAbort:  shows "result for react" and stays; log: "aborted re"
What’s happening
  1. The test replaced fetch with a fake whose responses it releases by hand and which honours signal. It rendered query="re", then query="react", released the react response, and then the re response.
  2. SearchNaive: both requests are live and both .then callbacks call setResults. The last one to arrive — the stale re — wins: the final text was result for re while the query was react.
  3. SearchAbort: when query changed, React ran the previous run's cleanup, controller.abort(), before starting the react request. The re request was cancelled (the fake logged aborted re) and its await rejected with an AbortError, which the catch deliberately ignores.
  4. The react request completed normally and the screen stayed on result for react. The SearchIgnore version (the ignore flag from Async code in effects) ended the same way: the stale response arrived but was dropped.
  5. Abort is better when you can use it — the browser actually stops the download — but the flag works with any promise, including APIs that don't take a signal.

My notes' version, a useFetchData hook (written in TypeScript there), marked "IMPORTANTTTTT: Abort Controller to prevent memory leaks":

useFetchData.js
import { useState, useEffect } from "react";

const useFetchData = (url) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    const signal = controller.signal;

    const fetchData = async () => {
      try {
        setLoading(true);
        const response = await fetch(url, { signal });
        if (!response.ok) {
          throw new Error(`Error: ${response.statusText}`);
        }
        const json = await response.json();
        setData(json);
        setLoading(false);
      } catch (err) {
        if (err.name !== "AbortError") {
          setError(err);
          setLoading(false);
        }
      }
    };

    fetchData();

    // Cleanup function to cancel the fetch request if the component unmounts
    return () => {
      controller.abort();
    };
  }, [url]);

  return { data, loading, error };
};
What’s happening
  1. Each run of the effect creates its own AbortController and passes its signal to fetch.
  2. response.ok is checked because fetch only rejects on network failure — a 404 or 500 resolves normally and must be turned into an error by hand.
  3. When url changes or the component unmounts, the cleanup aborts that run's request. The pending fetch (or response.json()) rejects with an error whose name is "AbortError".
  4. The catch skips state updates for aborts, so a cancelled request never shows an error or flips loading. Any other error is stored.
  5. The code is right. The comment is only half the story: the cleanup runs on every url change, not just unmount, and that's what prevents the race. (The full hook, with tests, is in Chapter 7, useFetch: a data-fetching hook.)

Correcting my notes on "memory leaks": the notes say AbortController (or a "mounted" variable) prevents memory leaks on unmount. That framing comes from an old React warning — "Can't perform a React state update on an unmounted component… indicates a memory leak" — which React 18 removed, because in most cases there was no leak: a finished fetch that calls setState on an unmounted component is simply a no-op (the test confirmed React 19.3 logs nothing). The real problems are race conditions (stale data shown) and wasted work (downloads nobody needs). Real leaks come from things that keep running — intervals, subscriptions, listeners — and those need cleanup.

My notes' second way, "use a variable and only update state if mounted", is right when the variable belongs to one effect run — that's exactly the ignore flag. What to avoid is the old class-era pattern of a single component-wide isMounted flag (this._isMounted or an isMountedRef): it doesn't protect against races between two runs, because the component stays mounted the whole time.

#Strict Mode runs effects twice

In development, inside <StrictMode>, React 18 and later mount every component, immediately unmount it, and mount it again. Effects therefore run setup → cleanup → setup on mount. It doesn't happen in production builds. React also calls component functions twice during rendering to surface impure rendering.

Why would React do this on purpose? It's a fire drill for your cleanup. Features like <Activity>, Fast Refresh, and remounting a tab you navigated back to all unmount and remount components while keeping their state. If your setup and cleanup are symmetric, mounting twice is invisible to the user. If they're not — a missing clearInterval, a subscription you never close — the drill exposes it in development instead of in production. My notes called this "strict effects".

ChatRoom.jsx
function ChatRoom({ roomId }) {
  console.log(`render ${roomId}`);
  useEffect(() => {
    console.log(`connect ${roomId}`);
    return () => console.log(`disconnect ${roomId}`);
  }, [roomId]);
  return <h1>{roomId}</h1>;
}

// render(<StrictMode><ChatRoom roomId="general" /></StrictMode>)
// render general
// render general
// connect general
// disconnect general
// connect general
// rerender with "travel":
// render travel
// render travel
// disconnect general
// connect travel
// unmount:
// disconnect travel
What’s happening
  1. Mount: the component function runs twice (render general ×2). React discards one result; the double call exists to catch render functions that mutate things outside themselves.
  2. Then the effect runs, React immediately runs its cleanup, then runs it again: connect, disconnect, connect. Because the cleanup undoes the setup, the net result is one open connection — exactly what production does with a single connect.
  3. On the update to "travel", there is no extra cycle: the double setup only happens on mount. You see two renders, then the normal cleanup-then-setup pair.
  4. Unmount runs the last cleanup once. Every connect has a matching disconnect, which is the property Strict Mode is checking.
  5. In React 19.3 both renders' console.log calls print (the test counted 2 calls); React DevTools shows the second one dimmed.

From my notes, the missing-cleanup interval — tested inside <StrictMode> for one second:

React
useEffect(() => {
  const interval = setInterval(() => {
    console.log("Tick");
  }, 1000);

  // Missing cleanup: This would cause multiple intervals to run in development mode
}, []);
// after 1s: Tick, Tick — 2 intervals alive

useEffect(() => {
  const interval = setInterval(() => {
    console.log("Tick");
  }, 1000);

  return () => {
    clearInterval(interval); // Cleanup the interval
  };
}, []);
// after 1s: Tick — 1 interval alive
What’s happening
  1. Without cleanup, Strict Mode's setup → cleanup → setup creates an interval, "cleans up" by doing nothing, then creates a second one. After one second the test logged Tick twice and counted 2 live timers.
  2. With cleanup, the first interval is cleared during the simulated unmount, so only the second one survives: one Tick per second, 1 live timer.
  3. My notes are right that this shows up "in development mode" — and the double tick is the symptom pointing at the real bug: in production the leaky version would still leak an interval on every real unmount.

Data fetching in Strict Mode sends two requests in development. With an ignore flag or an AbortController, the first response is dropped and only one state update happens:

JavaScript
// Fetcher with an ignore flag, inside <StrictMode>:
// request sent
// request sent
// response ignored
// state set
What’s happening
  1. The first setup sends a request; the simulated unmount runs its cleanup and sets that run's ignore = true.
  2. The second setup sends another request with its own ignore = false.
  3. The first response arrives and is ignored; the second sets state. The user sees one update. In production there'd be only one request.
  4. If the duplicate request matters (it costs money, or it's a POST), that's a sign it shouldn't be in an effect at all — mutations belong in event handlers, and a caching data library deduplicates GETs.
AdvancedEverything Strict Mode does in React 19
  • Calls component functions, useState/useReducer initialisers and updater functions, useMemo callbacks, and class constructor/render/getDerivedStateFromProps twice during rendering.
  • Runs effect setup → cleanup → setup once on mount (classes: componentDidMount → componentWillUnmount → componentDidMount on the same instance).
  • Runs ref callbacks setup → cleanup → setup on mount too (new in React 19; see Chapter 6, Callback refs and ref cleanup).
  • Warns about deprecated APIs such as the UNSAFE_ lifecycles.
  • In React 19, the second render reuses the memoised results from the first (useMemo/useCallback), and the double-invoked render's logs are no longer suppressed (React 17 silenced them; React 18 and 19 print them, dimmed in DevTools).
  • None of this happens in production builds, and it applies only to the subtree inside <StrictMode>.

#useLayoutEffect

Added

useLayoutEffect has the same signature as useEffect, but runs at a different time: after React has updated the DOM but before the browser paints. React runs it synchronously, and if it sets state, React re-renders synchronously too — still before paint. So the user never sees the intermediate state.

The picture: useEffect is tidying up after the guests have arrived; useLayoutEffect is tidying up while the door is still closed. Use it when you must measure the DOM and adjust before anything is visible — tooltip positioning, keeping scroll positions, preventing a flash of the wrong layout. Use useEffect for everything else, because layout effects block painting.

Order.jsx
function Order() {
  console.log("render");
  useEffect(() => {
    console.log("useEffect");
    return () => console.log("useEffect cleanup");
  });
  useLayoutEffect(() => {
    console.log("useLayoutEffect");
    return () => console.log("useLayoutEffect cleanup");
  });
  return <p ref={(node) => { if (node) console.log("ref attached"); }}>hi</p>;
}
// mount:   render, ref attached, useLayoutEffect, useEffect
// unmount: useLayoutEffect cleanup, useEffect cleanup
What’s happening
  1. render comes first: React calls the component and gets JSX. Nothing is in the DOM yet.
  2. React writes the <p> into the DOM, then attaches refs — ref attached. Refs are set before any layout effect runs, so a layout effect can always read ref.current.
  3. useLayoutEffect runs next, synchronously, before the browser paints. Even though it was declared after useEffect, it runs first.
  4. useEffect runs last, after paint (in a test environment there's no paint, but the order is the same).
  5. On unmount, layout effect cleanups run before passive effect cleanups.

A tooltip that needs its own height to decide where to go:

Tooltip.jsx
function Tooltip({ targetRect, children }) {
  const ref = useRef(null);
  const [tooltipHeight, setTooltipHeight] = useState(0);

  useLayoutEffect(() => {
    const { height } = ref.current.getBoundingClientRect();
    setTooltipHeight(height); // re-renders before the browser paints
  }, []);

  let top = targetRect.top - tooltipHeight;
  if (top < 0) top = targetRect.bottom; // not enough room above: flip below

  return (
    <div ref={ref} role="tooltip" style={{ position: "absolute", top }}>
      {children}
    </div>
  );
}
// target at top 30, bottom 50; the tooltip measures 40px tall
// render with height 0 → measured 40 → render with height 40 → top: 50px (below)
What’s happening
  1. First render: tooltipHeight is 0, so top = 30 - 0 = 30 — a guess. React puts that into the DOM.
  2. The layout effect measures the real element: 40px (the test mocked getBoundingClientRect, because jsdom has no layout engine). It calls setTooltipHeight(40).
  3. Because this happened in a layout effect, React re-renders synchronously, before paint: top = 30 - 40 = -10, which is negative, so it flips below the target: top = 50.
  4. The browser paints once, with the tooltip at 50px. The test confirmed the render order and the final top: 50px.
  5. With useEffect instead, the browser could paint the first guess (top: 30px, overlapping the target) for a frame and then jump — a visible flicker. That flicker is the whole reason useLayoutEffect exists.
AdvanceduseInsertionEffect

There's a third effect hook, useInsertionEffect, which runs before layout effects and before refs are attached (the test logged render, useInsertionEffect, ref attached, useLayoutEffect, useEffect). It exists for CSS-in-JS libraries to inject <style> tags before anything measures layout. It can't update state and can't read refs. Application code should never need it.

#useEffectEvent

Added

Sometimes an effect needs to read a value without reacting to it. The classic example: a chat room connects to roomId and shows a "Connected!" notification in the current theme. theme must be read inside the effect, so the linter makes you list it — and then switching the theme reconnects the chat, which is wrong. theme isn't something the connection depends on; it's just something the notification wants to know when it fires.

useEffectEvent (stable since React 19.2, October 2025) solves this. You wrap the non-reactive logic in a function; the function always sees the latest props and state when it's called, but it isn't a dependency, so it never causes the effect to re-run. The mental model: an effect is the plumbing (re-installed when the pipes change), and an effect event is a tap on that plumbing (you can change what comes out without re-plumbing). The installed type is useEffectEvent<T extends Function>(callback: T): T.

ChatRoom.jsx
import { useEffect, useEffectEvent } from "react";

// ❌ theme is a dependency, so changing it reconnects
function ChatRoomBad({ roomId, theme }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", () => showNotification("Connected!", theme));
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, theme]);
  return <h1>{roomId}</h1>;
}

// ✅ theme is read through an Effect Event
function ChatRoom({ roomId, theme }) {
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme);
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // onConnected is NOT a dependency

  return <h1>{roomId}</h1>;
}

// mount (general, light) → theme becomes dark → room becomes travel
// ChatRoomBad: connect general, notify (light) | disconnect general, connect general, notify (dark)
//              | disconnect general, connect travel, notify (dark)
// ChatRoom:    connect general, notify (light) | (nothing)
//              | disconnect general, connect travel, notify (dark)
What’s happening
  1. Both versions mount the same way: connect to general, then the connected callback shows a notification in the light theme.
  2. Theme changes to dark. In ChatRoomBad, theme is in the dependency array, so React runs cleanup and setup: it disconnects and reconnects to the same room just to change a colour. The test log shows disconnect general, connect general.
  3. In ChatRoom, the dependency array is only [roomId], which didn't change, so nothing happens. But onConnected was re-created during this render and now closes over theme = "dark".
  4. Room changes to travel: both versions reconnect (that's correct, the connection depends on the room). When connected fires, ChatRoom calls onConnected(), which reads the latest theme — the log says notify "Connected!" (dark), not light.
  5. So the effect reacts only to roomId, while the notification always uses the current theme. That split — reactive (roomId) versus non-reactive (theme) — is exactly what useEffectEvent expresses.

The rules, from react.dev and enforced by eslint-plugin-react-hooks (you need a recent version so it doesn't add the event to the dependency array):

  • Call the function only from inside effects (useEffect, useLayoutEffect, useInsertionEffect) or other effect events of the same component. Calling it during render throws — the test got "A function wrapped in useEffectEvent can't be called during rendering."
  • Don't put it in a dependency array, and don't pass it to other components or hooks. Its identity changes on every render on purpose (the test confirmed it isn't the same function after a re-render), so it can't be used as a stable callback.
  • Don't use it to silence the linter. If the effect really should re-run when a value changes, that value is a dependency. Use an effect event only for logic that's genuinely an event fired from the effect — "connected", "tick", "message received" — that wants the latest values.
AdvancedBefore 19.2: the "latest ref" pattern

Before useEffectEvent was stable, people wrote the same thing by hand: keep the latest value in a ref, update it after each render, and read the ref inside the effect.

useLatest.js
function useLatest(value) {
  const ref = useRef(value);
  useLayoutEffect(() => {
    ref.current = value; // update after every commit
  });
  return ref;
}

function ChatRoomOld({ roomId, theme }) {
  const themeRef = useLatest(theme);
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", () => showNotification("Connected!", themeRef.current));
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // the ref object is stable, so it needn't be listed
  return <h1>{roomId}</h1>;
}
// same log as ChatRoom: no reconnect on theme change; "travel" notifies in dark
What’s happening
  1. useRef returns the same object every render, so it doesn't need to be a dependency.
  2. The layout effect writes the latest theme into ref.current after every commit — before any passive effect or later callback reads it. (Writing a ref during render instead is discouraged, because a render can be thrown away.)
  3. The connection callback reads themeRef.current at the moment it fires, so it gets the newest theme. The test produced the same log as the useEffectEvent version.
  4. It works, but it's boilerplate and easy to get subtly wrong. You'll see it (or libraries' useEvent/useLatestCallback) in code written before React 19.2. An earlier experimental version of useEffectEvent was available as experimental_useEffectEvent in React's experimental builds.
AdvancedAnother use: a timer that logs the latest value
Logger.jsx
function Logger({ count }) {
  const onTick = useEffectEvent(() => console.log(`tick sees count ${count}`));

  useEffect(() => {
    const id = setInterval(() => onTick(), 1000);
    return () => clearInterval(id);
  }, []); // the interval is set up once

  return null;
}
// count=1, wait 1s → "tick sees count 1"; count=2, wait 1s → "tick sees count 2"
What’s happening
  1. The interval is created once and never restarted, because the effect has no dependencies.
  2. Without useEffectEvent, a callback inside that effect would close over the first render's count forever — the classic stale-closure bug — and always log 1.
  3. onTick always runs the latest render's version, so after the prop changes to 2 the next tick logs 2. The interval wasn't reset, so the timing stays regular.

#You might not need an effect

Added

Effects are an escape hatch for synchronising with things outside React. A lot of effect code in real projects isn't that — it's transforming data or responding to user actions, and putting it in an effect makes it slower (an extra render) and buggier (stale values, loops, events that fire at the wrong time). The React docs have a whole page with this title, and it's a favourite interview topic.

The rule of thumb: if there's no external system involved, you probably don't need an effect. Ask "why does this code run?" If the answer is "because the component was displayed", it's an effect. If it's "because the user did something", it's an event handler. If it's "because I can compute it from props/state", it's a plain variable in render.

FullName.jsx
// ❌ redundant state + effect
function FormWithEffect() {
  const [first, setFirst] = useState("Ada");
  const [last] = useState("Lovelace");
  const [fullName, setFullName] = useState("");
  useEffect(() => {
    setFullName(`${first} ${last}`);
  }, [first, last]);
  // …
}
// mount: render "" → render "Ada Lovelace"
// type "!": render "Ada Lovelace" → render "Ada! Lovelace"

// ✅ compute during render
function Form() {
  const [first, setFirst] = useState("Ada");
  const [last] = useState("Lovelace");
  const fullName = `${first} ${last}`;
  // …
}
// mount: render "Ada Lovelace"
// type "!": render "Ada! Lovelace"
What’s happening
  1. In FormWithEffect, the first render has fullName = "" — the screen briefly shows an empty name. Then the effect runs, calls setFullName, and a second render shows Ada Lovelace.
  2. Typing ! updates first. The next render still shows the old full name (Ada Lovelace), because the effect hasn't run yet; only the second render shows Ada! Lovelace. Every change costs two renders, and one of them shows stale data.
  3. Form computes fullName from state on every render. One render per change, always consistent — the test logged exactly one render for the mount and one for the keystroke.
  4. If the computation is expensive, wrap it in useMemo (Chapter 10) — still no effect and no extra state.

User actions belong in event handlers:

Product.jsx
// ❌ the toast is in an effect that reacts to inCart
function Product({ product, onAddToCart }) {
  const [inCart, setInCart] = useState(product.inCart);
  useEffect(() => {
    if (inCart) showToast(`Added ${product.name} to cart`);
  }, [inCart, product.name]);
  return <button onClick={() => { setInCart(true); onAddToCart(); }}>Add</button>;
}
// click Add → toast; reload the page (product already in cart) → toast again ❌

// ✅ the toast is part of the click
function ProductFixed({ product, onAddToCart }) {
  const [inCart, setInCart] = useState(product.inCart);
  function handleAdd() {
    setInCart(true);
    onAddToCart();
    showToast(`Added ${product.name} to cart`);
  }
  return <button onClick={handleAdd}>Add</button>;
}
// click Add → toast; reload → no toast
What’s happening
  1. In Product, the toast fires whenever the component renders with inCart === true. After the click, that's right.
  2. But after a reload, the product starts with inCart: true, so the effect runs on mount and shows "Added Mug to cart" again — though the user did nothing. The test logged the toast twice.
  3. In ProductFixed, the toast is part of handleAdd, so it happens because the user clicked and at no other time. After the reload, the test logged no second toast.
  4. The question that sorts this: is the code caused by displaying the component (effect) or by a specific interaction (event handler)? A toast, a POST request, or navigation after a submit are interactions.

The common cases and their replacements:

You wrote an effect to…Do this instead
Derive a value from props/stateCompute it during render (useMemo if it's expensive)
Reset all state when a prop changesGive the component a key (Chapter 4, getDerivedStateFromProps and getSnapshotBeforeUpdate)
Adjust some state when a prop changesStore the previous prop and set state during render — or better, compute it
Respond to a click/submit (POST, toast, navigate)Put it in the event handler
Chain effects (A's effect sets B, B's effect sets C)Calculate everything in the one event handler that started it
Tell the parent about a state change (onChange in an effect)Call the parent's callback in the same event handler that sets the state — or lift the state up
Subscribe to an external storeuseSyncExternalStore (Chapter 7)
Fetch dataAn effect with cleanup is OK; a data library or framework loader is better
Initialise the app once (analytics, auth check)Module-level code, outside components
Effect or not?

For each, would you use an effect?

  1. Filter a list of todos by the selected tab.
  2. Connect to a WebSocket while a chat panel is open.
  3. Send an analytics event when a checkout page is shown.
  4. Send a POST when the user clicks "Buy".
Show answer

1 — no, 2 — yes, 3 — yes, 4 — no.

What’s happening
  1. Filtering is a pure calculation from props/state: const visible = todos.filter(...) during render, with useMemo if the list is huge.
  2. A WebSocket is an external system that must stay connected while the panel is shown and close when it isn't — the textbook effect, with a cleanup that closes the socket.
  3. "The page was shown" is caused by displaying the component, so an effect is right. In development Strict Mode it fires twice; that's acceptable for analytics (or dedupe in the analytics layer).
  4. "The user clicked Buy" is an interaction. Put the POST in the click handler — in an effect it could fire again on remount, and Strict Mode would send it twice in development.

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.