RizTech Academy logo
RizTech Academy
React FundamentalsLesson 5 of 730 min

useEffect, and why you need it less than you think

useEffect is the most overused hook in React. This lesson covers what it does, and spends most of its length on the cases where people reach for it and should not — because in a Next.js application, that is most of them.

What it is for

An effect runs code after a render, to synchronise with something outside React: a browser API, a subscription, a timer, a third-party library.

"use client";

import { useEffect, useState } from "react";

export function WindowWidth() {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }

    handleResize();
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return <p>{width}px</p>;
}

Three parts:

The function runs after render.

The cleanup, returned from it, runs before the next effect and when the component unmounts. Without it here, every mount adds another listener and none are ever removed — a genuine memory leak.

The dependency array, []. Empty means "run once after the first render". Values inside mean "run again when any of them changes". Omitting it entirely means "run after every render", which is almost always a mistake.

The dependency array

useEffect(() => { ... });            // every render — rarely right
useEffect(() => { ... }, []);        // once, on mount
useEffect(() => { ... }, [userId]);  // when userId changes

The rule: every value from the component used inside the effect must be in the array. The ESLint rule react-hooks/exhaustive-deps enforces this, and it is right far more often than you will believe when it first annoys you.

Leaving something out gives you an effect closing over a stale value — it keeps using the version from the render it was created in, and the bug appears as "this updates sometimes".

The infinite loop:

useEffect(() => {
  setCount(count + 1);       // sets state → re-render → effect runs → ...
}, [count]);

Your browser tab freezes. If that happens, look here first.

When not to use it

This is the important half.

Not for data fetching in Next.js

"use client";

export function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch("/api/products")
      .then((r) => r.json())
      .then((data) => { setProducts(data); setLoading(false); });
  }, []);

  if (loading) return <p>Loading…</p>;
  return <ul>{products.map(...)}</ul>;
}

This is the pattern every older React tutorial teaches, and in a Next.js App Router project it is the wrong default.

What it costs: the browser downloads HTML with nothing in it, downloads JavaScript, runs it, then asks for the data, then renders. The user sees a spinner for two round trips. Google's crawler sees an empty page. And you have written loading and error handling by hand.

The server component version:

export default async function ProductList() {
  const products = await getProducts();
  return <ul>{products.map(...)}</ul>;
}

No effect, no state, no loading flag, no "use client". The data is fetched on the server and the HTML arrives complete. That is module 3, and it is why this lesson comes before it.

Fetch on the server by default. Use an effect for data only when it must happen in the browser after an interaction — and even then a library or a server action is usually better.

Not for derived values

const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);

useEffect(() => {
  setTotal(items.reduce((s, i) => s + i.price, 0));
}, [items]);

An extra render every time, and a window where total is stale.

const total = items.reduce((s, i) => s + i.price, 0);

The state lesson's rule: if it can be calculated, calculate it.

Not for responding to events

useEffect(() => {
  if (submitted) {
    sendAnalytics();
    setSubmitted(false);
  }
}, [submitted]);

A state flag used as a message. Put it in the handler:

function handleSubmit() {
  sendAnalytics();
}

Effects are for synchronising with the outside world, not for reacting to things that happened inside your own code. You already know when the button was clicked — you handled the click.

Not for resetting state on prop change

useEffect(() => {
  setQuantity(1);
}, [productId]);

Renders once with the old value, then again with the new. Use a key instead:

<ProductDetail key={product.id} product={product} />

Changing the key tells React this is a different component, so it discards the old state entirely. Cleaner and no extra render.

What it is legitimately for

  • Browser APIs: window, document, localStorage, matchMedia
  • Event listeners on window or document
  • Timers: setInterval, setTimeout
  • Subscriptions: WebSockets, third-party libraries
  • Anything imperative that must happen after the DOM exists

Notice the theme: something outside React that React cannot see.

localStorage and hydration

A trap specific to server-rendered apps:

const [theme, setTheme] = useState(localStorage.getItem("theme"));

localStorage does not exist on the server. That crashes during rendering.

const [theme, setTheme] = useState("light");

useEffect(() => {
  const saved = localStorage.getItem("theme");
  if (saved) setTheme(saved);
}, []);

Start with a value the server can produce, then read the browser's after mount. There is a brief flash of the default, which is the cost of the server not knowing what the browser has stored.

Effects run twice in development

React's Strict Mode deliberately mounts, unmounts and remounts every component in development. Your effect runs twice.

This is not a bug to work around — it is React showing you that your cleanup is missing or wrong. An effect with correct cleanup handles a double mount without noticing. If something breaks only in development, you have found a real problem that would surface in production later.

Check your work

Why the cleanup matters: without it every mount adds another listener and none are removed, which is a genuine memory leak.

What an empty dependency array means: run once after the first render. Omitting the array entirely means after every render.

Why setting state the effect depends on freezes the tab: state change → re-render → effect runs → state change, forever.

What fetching in an effect costs in Next.js: the browser downloads empty HTML, then JavaScript, then the data — two round trips before anything appears, and crawlers see nothing. A server component avoids all of it.

Why derived values should not be in an effect: it adds a render and leaves a window where the value is stale. Calculate it instead.

Resetting state on a prop change: use a key rather than an effect. The effect renders once with the old value first.

Why localStorage in useState breaks: it does not exist on the server. Read it in an effect after mounting.

Why effects run twice in development: Strict Mode deliberately remounts, to reveal missing or incorrect cleanup.

Practice

  1. Build the window width component. Remove the cleanup and confirm listeners accumulate by logging inside the handler.
  2. Remove the dependency array and watch the effect run constantly.
  3. Write an effect that sets state it depends on. Freeze the tab. Fix it.
  4. Fetch products in an effect with loading and error states. Count the lines. Keep it — module 3 replaces it and the comparison is the point.
  5. Derive a total in an effect, then replace it with a calculation.
  6. Reset state on a prop change with an effect, then with a key.
  7. Read localStorage in useState and see it break. Fix it with an effect.
  8. Add a console.log inside an effect and watch it run twice in development.

Next: rendering lists, and the prop everybody gets wrong.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship