React – The Component Lifecycle with useEffect

July 24, 20266 min readUpdated 8/18/2026

Older React tutorials talk about a component lifecycle with three phases — mounting, updating, unmounting — and a method for each. Function components have no such methods. They have useEffect, and it covers all three.

The more important shift is in how you are meant to think about it. An effect is not "code that runs after mount". It is a way to synchronise your component with something outside React — a server, a subscription, a timer, the browser API. Once you read it that way, the dependency array and the cleanup function stop being rules to memorise.

The mapping

Class methodHook equivalent
componentDidMountuseEffect(fn, [])
componentDidUpdateuseEffect(fn, [deps])
componentWillUnmountthe function fn returns
componentDidCatchnone — still needs a class

That last row is not an oversight. Error Boundaries are the one thing hooks never replaced.

The shape

useEffect(() => {
  // 1. the effect — runs after the render is committed to the screen
  return () => {
    // 2. the cleanup — runs before the next effect, and on unmount
  };
}, [/* 3. dependencies */]);

The dependency array

It decides when the effect runs again, and there are exactly three cases:

useEffect(() => { /* … */ });              // after EVERY render — almost always a mistake
useEffect(() => { /* … */ }, []);          // once, after the first render
useEffect(() => { /* … */ }, [reloadToken]);   // when reloadToken changes

The array must list every reactive value the effect reads — every prop, state variable, or value derived from them. The react-hooks/exhaustive-deps lint rule checks this, and when it disagrees with you it is almost always right.

Lying to it is the single biggest source of effect bugs. If the rule wants a dependency you do not want to react to, the answer is to restructure — move the value into a ref, wrap the function in useCallback, or compute it inside the effect — not to delete it from the array.

A real effect, in full

Fetching the menu. Everything in here is load-bearing:

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

  async function load() {
    setLoading(true);
    setError(null);
    try {
      const [productData, toppingData, crustData] = await Promise.all([
        api.get<Product[]>('/api/products', { signal: controller.signal }),
        api.get<Topping[]>('/api/toppings', { signal: controller.signal }),
        api.get<Crust[]>('/api/crusts', { signal: controller.signal }),
      ]);
      setProducts(productData);
      setToppings(toppingData);
      setCrusts(crustData);
    } catch (err) {
      // An abort is not a failure — it means we navigated away or StrictMode re-ran the effect.
      if (controller.signal.aborted) return;
      setError(
        err instanceof Error ? `Could not load the menu: ${err.message}` : 'Could not load the menu.',
      );
    } finally {
      if (!controller.signal.aborted) setLoading(false);
    }
  }

  void load();
  return () => controller.abort();
}, [reloadToken]);

Four decisions worth copying:

The async function is declared inside and then called. The effect callback itself cannot be async — an async function returns a promise, and React expects the return value to be a cleanup function. useEffect(async () => …) gives you a warning and a cleanup that never runs.

AbortController, and cleanup that uses it. This is what makes the effect safe to run twice, cancel mid-flight, or unmount during. Without it, navigating away while a request is in flight sets state on a component that is gone, and two overlapping requests can finish out of order with the stale one winning.

An abort is caught and ignored. A cancelled request throws; treating that as a failure would flash "Could not load the menu" every time the user navigated away.

Promise.all, not three awaits. The three requests are independent, so serialising them would triple the wait for nothing.

Cleanup

The returned function runs before the effect runs again, and once more when the component unmounts. Anything the effect started, the cleanup stops:

// a timer
useEffect(() => {
  const timer = window.setTimeout(() => { /* … */ }, 300);
  return () => window.clearTimeout(timer);
}, [state, hydrated]);

// a subscription
useEffect(() => {
  const list = window.matchMedia(query);
  list.addEventListener('change', onChange);
  return () => list.removeEventListener('change', onChange);
}, [query]);

// a request
useEffect(() => {
  const controller = new AbortController();
  /* … */
  return () => controller.abort();
}, []);

The debounced cart save is a nice case, because the cleanup is what produces the debounce. Clicking "+" three times quickly re-runs the effect three times, and each run cancels the previous timer — so only the last one fires:

useEffect(() => {
  // Never write before hydrating — that would overwrite the saved cart with an empty one.
  if (!hydrated) return;

  // Debounced: clicking "+" three times quickly is one write, not three.
  const timer = window.setTimeout(() => {
    void (async () => { /* … PUT the cart … */ })();
  }, 300);

  return () => window.clearTimeout(timer);
}, [state, hydrated]);

Note the early return before any work — that is allowed, and returning nothing simply means there is no cleanup for that run.

StrictMode runs it twice

In development, React mounts your component, runs the effect, runs the cleanup, and runs the effect again. Every time. This is deliberate.

It is checking that your effect is resilient to being re-run, because in a real app it will be — every dependency change does exactly this. If the double run breaks something, the cleanup is missing or incomplete, and that bug would have shown up later in a worse place.

It does not happen in the production build. Deleting <StrictMode> to make the second request go away is fixing the smoke detector.

The infinite loop

Everyone writes this once:

// Effect sets state → state changes → effect runs → …
useEffect(() => {
  setProducts(filterProducts(products));
});                                          // no dependency array at all

Two variants of the same mistake. Missing the array entirely means "run after every render", and an effect that sets state causes a render. And this one, which looks correct:

// `options` is a new object every render, so the effect runs every render.
useEffect(() => {
  load(options);
}, [options]);

Objects, arrays and functions declared in the component body are new references on every render, so a dependency array containing one is never equal to the last. The fixes are useMemo or useCallback around the value, or depending on the primitive fields instead of the object.

You probably do not need an effect

This is the most useful thing in the post. Most effects people write should not exist.

Not for derived data

// WRONG — an extra render, a second source of truth, and a chance to go stale.
const [pizzas, setPizzas] = useState<Product[]>([]);
useEffect(() => {
  setPizzas(products.filter((p) => p.type === 'PIZZA'));
}, [products]);

// Right — just calculate it.
const pizzas = useMemo(() => products.filter((p) => p.type === 'PIZZA'), [products]);

Not for responding to an event

// WRONG — the click already happened; watching state to find out is indirect and fragile.
useEffect(() => {
  if (submitted) showToast('Added to your cart');
}, [submitted]);

// Right — do it where it happened.
function handleAdd() {
  addItem({ product, size, crust, toppings: selectedToppings, quantity });
  showToast(`${quantity} × ${product.name} added to your cart`);
  onHide();
}

The rule of thumb: an effect is for synchronising with something outside React. If both sides of the problem are inside React — state, props, a click — you almost certainly want a calculation during render or a line in an event handler instead.

Effects that are genuinely necessary

For completeness, the ones in this app, all of which pass that test:

  • fetching the menu from the server on first render
  • validating a stored auth token against /api/auth/me on startup
  • loading a saved cart, and persisting it back, debounced
  • resetting the builder form when a different product is opened

Every one of them crosses the boundary between React and something that is not React.

Next

Refs — the other escape hatch.