Hirefullstack – Software Engineering & IT-Beratung aus Berlin
← Back to overview
Core 16 min read

useEffect

The wire to the outside world – and the most misused hook

In one sentence

With useEffect you tell React: “once you have finished drawing, please also do this” – start a timer, say, or listen for key presses.

While drawing, your component may only compute and return. Everything else – starting a timer, listening for a keyboard event, changing the page title – happens afterwards, in an effect.

shape.tsx
useEffect(() => {
  // 1) This runs AFTER React has drawn

  return () => {
    // 2) This cleans up again
  };
}, [what, it, watches]);
//  ▲ the list at the end decides WHEN 1) runs again
Put another way

An effect is not “quickly do something afterwards”. It is a standing instruction: “as long as these values are what they are, this is how things should be outside.” Change the values and React tears down the old arrangement and sets up the new one.

The list at the end: three cases

deps.tsx
useEffect(() => { … });          // after EVERY draw – almost always wrong

useEffect(() => { … }, []);      // only the first time (and cleanup at the end)

useEffect(() => { … }, [roomId]); // whenever roomId changes
Careful The list is a promise, not a switch

You are not telling React “only run then”. You are claiming: “I use nothing in here beyond these values”. Leave something out that you do use, and the effect works with stale data. So: put in everything that comes from outside.

Cleaning up is mandatory

Whatever you start, you have to stop. React calls the function you return before the effect runs again – and one last time when the component disappears.

cleanup.tsx
// Timer
useEffect(() => {
  const id = setInterval(() => setTick((t) => t + 1), 1000);
  return () => clearInterval(id);        // otherwise you soon have 10 timers
}, []);

// Keyboard
useEffect(() => {
  function onKey(e) { if (e.key === "Escape") close(); }
  window.addEventListener("keydown", onKey);
  return () => window.removeEventListener("keydown", onKey);
}, [close]);

// Connection
useEffect(() => {
  const connection = chat.connect(roomId);
  return () => connection.disconnect();  // drop the old one when the room changes
}, [roomId]);
Good to know Why does my effect run twice?

In development StrictModeA checking mode that deliberately runs some things twice during development, to surface bugs early.None of this happens in the published app. deliberately does: start → clean up → start. If something breaks, your cleanup is missing – and the same bug would show up later when the user navigates back and forth. It does not happen in the published app.

The important part: when you do NOT need an effect

Most effects in real projects are unnecessary. Three patterns you can spot immediately:

1. Computing a value
draws twice, can go stale
const [first, setFirst] = useState("");
const [last, setLast] = useState("");
const [full, setFull] = useState("");

useEffect(() => {
  setFull(first + " " + last);
}, [first, last]);
just compute it
const [first, setFirst] = useState("");
const [last, setLast] = useState("");

const full = first + " " + last;
2. Reacting to a click
a detour through state
useEffect(() => {
  if (ordered) {
    showToast("Thanks!");
  }
}, [ordered]);
straight in the click handler
function handleBuy() {
  setOrdered(true);
  showToast("Thanks!");
}

Effects are for things that happen because the component is there – not because somebody clicked. Anything a click triggers belongs in the click handler.

3. Clearing fields when a different record arrives
briefly shows the old data
useEffect(() => {
  setDraft("");
  setError(null);
}, [userId]);
let React rebuild it
// in the component above:
<Profile key={userId} userId={userId} />

A new keyA name tag on list items so React knows which item is which – even when the order changes. means a different element to React. It throws the old one away, state and all, and builds fresh.

Reach for it when …
  • +

    Timers: setInterval, setTimeout

  • +

    Listening for events: addEventListener, connections, observers

  • +

    Touching browser things: page title, localStorage, playing a video

  • +

    Wiring up other libraries (maps, charts, editors)

Skip it when …
  • Computing values that follow from state or props

  • Reacting to clicks – the handler can do that

  • Setting state in order to trigger the next effect

  • Loading data in bigger projects – there are ready-made libraries

The most common infinite loop

runs again on every draw
const options = { url, retries: 3 };   // a NEW object every time

useEffect(() => {
  connect(options);
}, [options]);
runs only on a real change
useEffect(() => {
  const options = { url, retries: 3 };   // created inside the effect
  connect(options);
}, [url]);

Objects, arrays and functions are new on every pass – so “changed” as far as React is concerned. Keep the list to simple values where you can: strings, numbers, booleans.

Pitfall Never just silence the warning

When the LinterA tool that checks your code as you write and points out typical mistakes. reports a missing entry in the list, it is almost always a real bug. The fix is never to suppress it – it is to add the value, pull the function into the effect, or get rid of the effect entirely.

In more depth: loading data and the race problem optional

Loading data in an effect works – but there is a catch. If id changes quickly, two requests are in flight at once. Which one comes back first is chance. If the answer for 1 arrives later, it overwrites the one for 2 and you show the wrong user. This is called a Race conditionTwo things run at the same time and whichever finishes first is down to chance – with a different outcome each way..

the old answer can overwrite the new one
useEffect(() => {
  fetch("/api/user/" + id)
    .then((r) => r.json())
    .then(setUser);
}, [id]);
discard the stale answer
useEffect(() => {
  let current = true;

  fetch("/api/user/" + id)
    .then((r) => r.json())
    .then((data) => {
      if (current) setUser(data);
    });

  return () => { current = false; };
}, [id]);

The trick: on cleanup you set current to false. The old request still finishes, but its result is thrown away.

Tip In real projects you rarely do this by hand

Libraries such as TanStack Query or SWR solve races, caching, retries and loading states in one go. Or you load the data on the server – in Next.js that is the normal case.

Does it stick?

6 questions on this lesson. Wrong answers show up in your stats.

Now write it yourself

Stopwatch with proper cleanup

The stopwatch ignores the start/stop button and gets stuck at 1. Both bugs come from the effect: it watches nothing and uses a frozen value.

  • While running, the display goes up by 1 every second
  • After stopping it stays put
  • Before the first start nothing happens at all
import { useEffect, useState } from "react";

export default function App() {
  const [seconds, setSeconds] = useState(0);
  const [running, setRunning] = useState(false);

  useEffect(() => {
    // TODO: the timer ignores "running" and always computes with the old 0.
    const id = setInterval(() => setSeconds(seconds + 1), 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <div style={{ fontFamily: "system-ui", padding: 16 }}>
      <p data-testid="seconds" style={{ fontSize: 32 }}>{seconds}</p>
      <button onClick={() => setRunning((r) => !r)}>
        {running ? "Stop" : "Start"}
      </button>{" "}
      <button onClick={() => setSeconds(0)}>Reset</button>
    </div>
  );
}

Throw the pointless effect away

This search filters with an effect – so it draws twice and briefly shows an empty list on the first pass. Clean it up: without useEffect and without the second piece of state.

  • With no input, all four fruits appear
  • Typing ap leaves only Apple
  • The file no longer contains useEffect
import { useEffect, useState } from "react";

const FRUIT = ["Apple", "Banana", "Cherry", "Date"];

export default function App() {
  const [query, setQuery] = useState("");
  const [visible, setVisible] = useState<string[]>([]);

  // TODO: delete this effect entirely
  useEffect(() => {
    setVisible(
      FRUIT.filter((f) => f.toLowerCase().includes(query.toLowerCase()))
    );
  }, [query]);

  return (
    <div style={{ fontFamily: "system-ui", padding: 16 }}>
      <input
        aria-label="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search …"
      />
      <ul>
        {visible.map((f) => (
          <li key={f}>{f}</li>
        ))}
      </ul>
    </div>
  );
}

Hirefullstack

Need React firepower on your team?

We have been building React and Next.js applications for clients across Germany for years – as a single expert, as reinforcement for an existing team, or as a complete Scrum team.

Talk about your project →