Back to the Library

React useEffect Only Runs Once - Why State Changes Don't Update the UI

7 min read
“The effect ran. You saw it work, once, on the first render. Then you changed something - typed, clicked, switched a tab - and the effect just sat there, holding onto whatever it captured the first time.”

In This Post

What the Dependency Array Actually MeansCase One: The Frozen FilterThe Fix: List What You ReadCase Two: The Closure That Never UpdatesThe Fix: Stop Reading the Closure at AllCase Three: The Greeting That Won't Switch UsersThe One Question That Catches All ThreePractice These Patterns

Practice This Pattern

White Belt

Search Box Has No Effect - List Never Filters

A search box where typing updates the input but the results list never changes - the filtering effect has an empty dependency array and ran once, on an empty query.

+10 KI
Enter the Dojo
White Belt

Counter Freezes at 1

A live ticker that should count up every second but stops dead at 1 - the interval callback closed over count at zero on the very first render and never sees a new value.

+10 KI
Enter the Dojo
White Belt

Profile Panel Always Shows the First User

A profile panel that greets the first user forever - clicking a different user updates the button but the effect building the greeting never runs again.

+10 KI
Enter the Dojo
BugDojo
BlogFAQ

© 2026. Carved in code.

You wrote a useEffect. It ran when the page loaded, and it did exactly the right thing - filtered a list, started a ticker, showed a greeting.

Then you interacted with the page. Typed in a box. Clicked a different option. And the thing the effect was supposed to keep up to date... didn't. It just sat there, frozen on whatever it computed the first time.

This is the single most common useEffect bug, and it comes down to one line: }, []).

What the Dependency Array Actually Means

useEffect(() => {
  // do something
}, [query]);

The array at the end is not decoration. It is React's instruction for when to run this effect again. "Run it after every render where any value in this array is different from last time."

An empty array, [], means the list of "values that are different" is always empty. By definition, nothing in an empty list can ever change. So the effect runs exactly once - after the first render - and never again, for the entire lifetime of the component.

If your effect reads a variable from component scope - state, props, a computed value - and that variable isn't in the array, the effect doesn't know it should care. It keeps using whatever that variable was the first time.

Case One: The Frozen Filter

const ITEMS = ["Apple", "Banana", "Avocado", "Blueberry", "Cherry", "Apricot"];
 
export default function SearchList() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState(ITEMS);
 
  useEffect(() => {
    setResults(
      ITEMS.filter((item) =>
        item.toLowerCase().includes(query.toLowerCase())
      ),
    );
  }, []);
 
  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {results.map((item) => (
          <li key={item}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

On mount, query is "". The effect runs once, filters ITEMS by "" (which matches everything), and sets results to all six items. Then it never runs again.

Type "a". query becomes "a". The input updates - that's just a controlled input doing its job. But the effect that builds results is not listening. It ran once, with query locked at "", and [] tells React there is nothing left to watch. From React's side, this makes sense - you said "nothing changes," so nothing re-runs. From the developer's side, the catch is that query is changing on every keystroke, and the effect reads it, but the empty array is a promise to React that the effect doesn't care about query - a promise that turns out to be wrong.

Symptom

The input field updates correctly as you type - you can see your own characters appear. The list below it does not move, no matter what you type or how many characters you add.

The Fix: List What You Read

useEffect(() => {
  setResults(
    ITEMS.filter((item) =>
      item.toLowerCase().includes(query.toLowerCase())
    ),
  );
}, [query]);

One word added to the array. Now React's rule reads: "run this effect again after any render where query is different from last time." Type a character, query changes, the effect re-runs with the current query, and results is recalculated against what you actually typed.

Case Two: The Closure That Never Updates

The search box bug is about when the effect re-runs. This next one is about what it remembers when it doesn't.

export default function LiveTicker() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);
 
  return <p>Tick: {count}</p>;
}

This one is more confusing because it does not freeze immediately. The ticker goes from 0 to 1, then stops. Forever.

Here's why. The effect runs once, with count equal to 0. It creates a setInterval callback - and that callback is a closure. It permanently remembers count as it was at the moment the callback was created: 0.

One second later, the interval fires. It calls setCount(0 + 1), which is setCount(1). React updates count to 1 and re-renders. Tick: 1.

But the interval callback itself was never recreated - [] means the effect never re-runs, so the same closure with count frozen at 0 keeps firing. Every second, forever, it calls setCount(0 + 1) - which is setCount(1) again. count is already 1. Object.is(1, 1) is true. React skips the update. The ticker is stuck.

The Sensei's Hint

Adding count to the dependency array would "fix" this in a technical sense, but it would also tear down and recreate the interval every single second - which defeats the point of an interval and reintroduces timing bugs. This is the one case where the right fix isn't "add the dependency."

The Fix: Stop Reading the Closure at All

useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

setCount((c) => c + 1) is a functional updater. Instead of reading count from the closure, it asks React for whatever the current value actually is at the moment the update runs, and returns one more than that. The closure never needs to know what count is - so it doesn't matter that it's frozen.

[] is now correct, on purpose: this effect genuinely has nothing it needs to watch. It sets up one interval on mount and tears it down on unmount, and the interval itself stays correct forever because it never reads stale state.

Case Three: The Greeting That Won't Switch Users

const USERS = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
  { id: 3, name: "Carol" },
];
 
export default function ProfilePanel() {
  const [user, setUser] = useState(USERS[0]);
  const [greeting, setGreeting] = useState("");
 
  useEffect(() => {
    setGreeting(`Welcome, ${user.name}!`);
  }, []);
 
  return (
    <div>
      {USERS.map((u) => (
        <button key={u.id} onClick={() => setUser(u)}>{u.name}</button>
      ))}
      <p>{greeting}</p>
    </div>
  );
}

Same root cause as the search box, different shape. On mount, user is Alice, the effect runs once, greeting becomes "Welcome, Alice!" - and then [] makes sure it never runs again.

Click "Bob." setUser(u) updates user correctly - the button click works, the state changes, you can confirm it with a console.log(user) right in the component body. But the effect that reads user to build greeting already ran. It is not watching user. The greeting stays "Welcome, Alice!" no matter who you click.

useEffect(() => {
  setGreeting(`Welcome, ${user.name}!`);
}, [user]);

Add user, and every click that changes it re-runs the effect with the current user, producing the correct greeting.

The One Question That Catches All Three

For every value your effect reads from component scope - state, props, anything that isn't a setter function - ask: "if this changes, should the effect run again?"

If yes, it belongs in the array. If the effect reads it but genuinely shouldn't care when it changes (the setInterval case), don't add it - find a way to stop reading it directly, usually with a functional updater.

Constraints

An empty dependency array is not "no dependencies, ignore this." It is a claim: "this effect depends on nothing from the component's render scope, ever." If your effect's body mentions a variable that came from useState or props, and you're not using a functional updater for it, that claim is false - and ESLint's react-hooks/exhaustive-deps rule will tell you so.

Practice These Patterns

All three katas show [] doing the same thing - locking an effect's view of the world to whatever it was on the very first render. "Search Box Has No Effect" is the cleanest version: type, watch the input update, watch the list not move. "Counter Freezes at 1" adds the closure wrinkle - the effect does run repeatedly, it's just trapped reading a stale value, which is why the functional updater fix looks different from just adding a dependency. "Profile Panel Always Shows the First User" is the same bug as the search box in a different shape - work through it last to confirm the pattern holds regardless of what the effect is building.