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.”
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.
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.
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.
"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?").
"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.