The "run once" ref that Strict Mode turns into "run never"
A typing animation worked for a year under Vite and rendered nothing under Next. The guard protecting it was the bug.
My hero heading types itself out one character at a time. After moving the site to Next.js it rendered an empty line with a blinking cursor. Forever.
The hook had not changed. The component had not changed. What changed was that Next enables React Strict Mode by default, and my old Vite entry point never wrapped anything in <StrictMode>.
The guard
const hasRunRef = useRef(false);
useEffect(() => {
if (hasRunRef.current) return; // "only run once"
hasRunRef.current = true;
const timeout = setTimeout(typeNextChar, startDelay);
return () => clearTimeout(timeout);
}, [text]);
That reads like defensive code. In development Strict Mode it is a deadlock:
- Effect runs. Ref flips to
true. Timers start. - React immediately tears the effect down. Cleanup clears the timers.
- Effect runs again. The ref is already
true, so it returns early.
Nothing is scheduled any more and nothing ever will be. The heading stays empty.
The fix is deleting the guard
The cleanup function was already correct — it cleared every timer it created. Once cleanup is right, re-running is free, and the guard is not protecting anything. It is only suppressing the second run:
useEffect(() => {
let index = 0;
const timeout = setTimeout(typeNextChar, startDelay);
return () => clearTimeout(timeout);
}, [text]);
Strict Mode double-invokes precisely to surface effects that cannot survive a remount. Mine could not. It was right.
The part worth remembering
A "run once" ref and a cleanup function solve overlapping problems, and when you have both, the ref quietly wins. If an effect needs a ref to avoid running twice, the honest question is what the cleanup is failing to undo — not how to skip the second run.
I also would not have caught this from the terminal. The server HTML looked fine: the markup was there, the aria-label was there, only the animated text was missing. I found it by rendering the page in a headless browser at a few points in time and reading what the heading actually said — Building fullst at 1.2s, the full line by 9s. Static output cannot tell you an effect never fired.
