Back to the Library

React useEffect Fires on Page Load - Mount vs Update Explained

6 min read
“A toast, a notification, a welcome message - it appears the instant the page loads, before the user has clicked, typed, or done anything that should have triggered it. The effect isn't broken. It's just running on a render you forgot to count.”

In This Post

"Run When X Changes" Includes the First Time X Has a ValueThe Tempting Fix That Doesn't Work HereThe Real Fix: Guard on the Value, Not on Mount TimingWhy This GeneralizesPractice These Patterns

Practice This Pattern

Blue Belt

Filter Toast on Load

A filterable list shows a 'Filter applied' toast the instant the page loads, before the user has typed anything - the effect that's supposed to react to typing also runs on the very first render, when there's nothing to react to yet.

+25 KI
Enter the Dojo
BugDojo
BlogFAQ

© 2026. Carved in code.

The page loads. Before you've clicked, typed, or touched anything, a notification appears: "Filter applied."

You haven't applied a filter. You haven't done anything. The notification is just... there, on load, as if some interaction already happened.

"Run When X Changes" Includes the First Time X Has a Value

const ITEMS = ["Apple", "Banana", "Cherry", "Date", "Elderberry"];
 
export default function FilteredList() {
  const [filter, setFilter] = useState("");
  const [toast, setToast] = useState("");
 
  useEffect(() => {
    setToast("Filter applied");
  }, [filter]);
 
  const visible = ITEMS.filter((i) =>
    i.toLowerCase().includes(filter.toLowerCase())
  );
 
  return (
    <div>
      <input value={filter} onChange={(e) => setFilter(e.target.value)} />
      <ul>
        {visible.map((i) => <li key={i}>{i}</li>)}
      </ul>
      {toast && <p>{toast}</p>}
    </div>
  );
}

The intent is clear from the dependency array: "whenever filter changes, show a toast confirming the filter was applied." That reads as "this runs when the user types something."

But useEffect's dependency array doesn't distinguish between "this is the first render, and filter happens to have a value" and "the user just changed filter from one value to another." Both are, technically, "a render where filter is present as a dependency" - and React runs the effect after the first render too, the same as after every render where a dependency differs from the previous one.

On mount, there is no previous render to compare against - so by definition, this is treated as "everything changed," and the effect runs. filter is "", the effect runs, setToast("Filter applied") fires, and the toast appears - despite filter having never actually changed from anything.

Symptom

The toast is visible on the very first paint, with an empty input and an unfiltered list. No click, no keystroke, nothing the user did caused it - it's there from the moment the component first renders.

The Tempting Fix That Doesn't Work Here

A natural instinct is: "I just need to know if this is the first render, and skip the toast if so." A useRef flag looks like the obvious tool:

const hasMounted = useRef(false);
 
useEffect(() => {
  if (!hasMounted.current) {
    hasMounted.current = true;
    return;
  }
  setToast("Filter applied");
}, [filter]);

This looks correct, and in a simplified mental model it is - first run, hasMounted.current is false, set it to true and bail out; every run after that, hasMounted.current is true, so the toast fires.

The problem is React 18's StrictMode, which intentionally double-invokes effects (and component function bodies) during development specifically to help surface bugs like stale closures and missing cleanup. With this guard, that double-invocation plays out as: first invocation sets hasMounted.current = true and returns; second invocation immediately sees hasMounted.current already true and runs the toast logic - on mount, which is exactly the case this guard was trying to suppress.

The Sensei's Hint

A useRef mount-guard depends on "this effect runs exactly once on mount" - a timing assumption that StrictMode deliberately breaks in development by running effects twice. On the first invocation, hasMounted.current flips to true. On the second, the guard is already gone, and the toast fires - on mount, the exact case you were trying to block.

The Real Fix: Guard on the Value, Not on Mount Timing

useEffect(() => {
  setToast(filter ? "Filter applied" : "");
}, [filter]);

Instead of asking "is this the first time this effect has run," ask a question about the data: "is there actually a filter applied right now?"

On mount, filter is "". filter ? "Filter applied" : "" evaluates to "". setToast("") - no visible toast, because toast && <p>{toast}</p> doesn't render anything for an empty string.

Type a character. filter becomes "a". The effect re-runs (because filter changed - a real change this time, not just "first render with a value"). filter ? "Filter applied" : "" now evaluates to "Filter applied". The toast appears - now correctly tied to "the user has typed something," which is what this was supposed to mean all along.

This version has a bonus the mount-guard version doesn't: clear the input back to empty, and the toast correctly disappears too, because filter ? ... : "" re-evaluates to "" again. The mount-guard version, even if it worked, would have no way to "un-fire" - once past the guard, every future filter change shows the toast, including going back to empty.

Constraints

This effect's correctness doesn't depend on whether it's the 1st, 2nd, or Nth time it has run - and it shouldn't. It depends entirely on the current value of filter. Whenever an effect's behavior needs to differ between "mount" and "update," look for a guard expressed in terms of a value already in scope (if (!filter) return, a ternary, a comparison) rather than a counter or flag tracking how many times the effect itself has fired.

Why This Generalizes

"Mount" isn't a special event that effects can detect cleanly - it's just "the first render, where every dependency is new because there's nothing to compare it to." Any effect written as "do X when Y changes" will also do X on mount, because Y did just go from "didn't exist" to "has its initial value," which counts as a change.

If X is something that should only happen in response to a genuine user action - a toast, a log entry, an animation - the fix is almost always to express the condition in terms of what X represents ("is there a filter to report on?") rather than in terms of when the effect is running ("is this the first time?").

Practice These Patterns

"Filter Toast on Load" is the only kata for this pattern, and it's worth working through both the wrong fix and the right one in sequence: try the useRef mount-guard first, confirm it appears to work, then clear the filter back to empty and watch the toast fail to disappear (or, under StrictMode, fail to suppress on mount in the first place). Then switch to the value-based guard and confirm both directions - typing and clearing - behave correctly.