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

Pitfalls: effects & closures

Endless loops, stale values, duplicate timers

Builds on: useEffect
In one sentence

Nearly every effect bug has one of four causes: the wrong list at the end, missing cleanup, an effect that should not exist, or a function holding stale values.

useEffect is the hook that eats the most time. The good news: it is the same four causes over and over.

1. The endless loop

endless.tsx
// a) no list at all + setState
useEffect(() => {
  setData(compute(x));
});                       // draw -> effect -> draw -> …

// b) an object or array in the list
const filter = { active: true };
useEffect(() => { load(filter); }, [filter]);   // filter is always new

// c) a function from the parent in the list
useEffect(() => { onLoad(); }, [onLoad]);       // onLoad is defined inline
Tip How to find the cause

Put a console.log in the effect and print the dependencies with it. Look at which one changes. In 90 % of cases it is an object, an array or a function created fresh on every pass.

Defusing a function in the list
Not like this
function Parent() {
  const load = () => fetch("/api");    // new on every pass
  return <Child load={load} />;
}

function Child({ load }) {
  useEffect(() => { load(); }, [load]);
}
Do this instead
function Parent() {
  const load = useCallback(() => fetch("/api"), []);
  return <Child load={load} />;
}

// Or better: move the function into the effect
// if only the child needs it.

2. Chains of effects

four draws, hard to follow
useEffect(() => {
  setFiltered(items.filter(matches));
}, [items]);

useEffect(() => {
  setSorted([...filtered].sort(cmp));
}, [filtered]);

useEffect(() => {
  setPage(sorted.slice(0, 20));
}, [sorted]);
one computation
const page = useMemo(() => {
  return items.filter(matches).sort(cmp).slice(0, 20);
}, [items]);

Every effect that only turns state into state costs an extra draw and shows inconsistent data in between. Just compute instead.

3. Forgotten cleanup

cleanup.txt
Without cleanup you typically get:

setInterval          -> after 10 switches, 10 timers are running
addEventListener     -> handlers fire repeatedly, memory leaks
WebSocket / SSE      -> connections stay open
IntersectionObserver -> watching nodes that no longer exist
fetch                -> setState on a component that has left the page
Pitfall StrictMode shows you

If your effect runs twice in development and something doubles up (two connections, duplicate rows), the cleanup is missing. StrictMode is the messenger here, not the culprit.

4. The effect that should not exist

no-effect.tsx
// ❌ reacting to a click
useEffect(() => { if (sent) toast("Thanks!"); }, [sent]);
// ✅ belongs in the handler

// ❌ resetting state when a prop changes
useEffect(() => { setDraft(""); }, [userId]);
// ✅ <Editor key={userId} />

// ❌ telling the parent
useEffect(() => { onChange(value); }, [value]);
// ✅ call onChange where value actually changes

// ❌ deriving
useEffect(() => { setFull(first + " " + last); }, [first, last]);
// ✅ const full = first + " " + last;

5. Functions holding stale values

Every pass creates new functions – with the values from exactly that pass. If an old function stays stuck in a timer or a listener, it keeps working with the values of that moment forever. The term for this is Stale closureA function works with outdated values because it was created in an earlier pass and holds on to the old ones..

closure.tsx
function Search({ onSearch }) {
  const [text, setText] = useState("");

  useEffect(() => {
    const id = setTimeout(() => onSearch(text), 500);   // text as of NOW
    return () => clearTimeout(id);
  }, [text, onSearch]);        // both values belong in here
}

// It gets dangerous when somebody "tidies" the list down to []:
// then the timer calls onSearch("") forever – with the text from
// the very first pass.
Careful Never switch the lint warning off

// eslint-disable-next-line react-hooks/exhaustive-deps is nearly always a hidden bug ticket. The legitimate exceptions are so rare that they deserve a comment explaining why.

Good to know Looking ahead: useEffectEvent

For the case “I want to read the current value but not react to it” there is a hook on the way (useEffectEvent, still experimental). Until then: mirror the value into a ref, or restructure the effect.

Does it stick?

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

Now write it yourself

Untangle the chain of effects

Three effects hand state along to each other. The result is eventually right, but it costs three extra draws and flickers. Replace the chain with a direct computation.

  • The display shows the three most expensive items, highest first
  • The file no longer contains useEffect
  • Only one piece of state is left: the raw data
import { useEffect, useState } from "react";

type Product = { name: string; price: number; active: boolean };

const PRODUCTS: Product[] = [
  { name: "Chair", price: 80, active: true },
  { name: "Table", price: 250, active: true },
  { name: "Lamp", price: 40, active: false },
  { name: "Shelf", price: 120, active: true },
  { name: "Sofa", price: 900, active: true },
];

export default function App() {
  const [products] = useState(PRODUCTS);
  const [activeOnes, setActiveOnes] = useState<Product[]>([]);
  const [sorted, setSorted] = useState<Product[]>([]);
  const [top, setTop] = useState<Product[]>([]);

  useEffect(() => {
    setActiveOnes(products.filter((p) => p.active));
  }, [products]);

  useEffect(() => {
    setSorted([...activeOnes].sort((a, b) => b.price - a.price));
  }, [activeOnes]);

  useEffect(() => {
    setTop(sorted.slice(0, 3));
  }, [sorted]);

  return (
    <div style={{ fontFamily: "system-ui", padding: 16 }}>
      <h3>Most expensive active products</h3>
      <ol>
        {top.map((p) => (
          <li key={p.name}>
            {p.name}: {p.price}</li>
        ))}
      </ol>
    </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 →