All posts

· 4 min read

Why your requestAnimationFrame animation restarts

The loop runs, then jumps back to the start, then runs again. Four causes, in the order you should check them, and the ref pattern that fixes most of it.

You wrote an animation loop in a useEffect. It works, mostly, except it keeps snapping back to the beginning — sometimes on mount, sometimes whenever a prop changes, sometimes twice in a row on page load and never again.

There are four common causes and they look identical from the outside. Check them in this order.

1. StrictMode is mounting you twice

In development, React 18 and later deliberately mount every component, run its effects, tear them down and run them again. It's finding exactly this class of bug, and it only happens in development.

If your animation starts, cancels and starts over once on load and never again, and it doesn't do it in a production build — this is it, and it's working as intended. What it's telling you is that your effect isn't idempotent. That's worth fixing rather than suppressing, because the same fragility shows up in production under Fast Refresh and remounts.

2. Your dependency array has something unstable in it

useEffect(() => {
  let raf;
  const tick = () => {
    setProgress((p) => p + 0.01);
    raf = requestAnimationFrame(tick);
  };
  raf = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(raf);
}, [onComplete]);  // ← a new function every render

If onComplete is defined inline by the parent, it's a different value on every render, so the effect tears down and re-runs on every render — cancelling the loop and starting a new one from the top.

The fix isn't to delete it from the array. It's to stop the effect from depending on it:

const onCompleteRef = useRef(onComplete);
useEffect(() => {
  onCompleteRef.current = onComplete;
});

useEffect(() => {
  let raf;
  const tick = () => {
    setProgress((p) => p + 0.01);
    raf = requestAnimationFrame(tick);
  };
  raf = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(raf);
}, []);  // ← runs once, reads the latest callback through the ref

The ref is updated in its own effect rather than during render, which matters: assigning to a ref while rendering is a side effect in the render phase, and React reserves the right to throw that render away.

3. You're driving the animation through state

const [progress, setProgress] = useState(0);

Sixty state updates a second means sixty renders a second, and every one of them reconciles your component and its children. On a simple component you'll get away with it. On anything real it drops frames, and the dropped frames look like stutter, which people then try to fix by changing the easing.

For anything running per-frame, write to the DOM directly and keep the value in a ref:

const elRef = useRef(null);
const progressRef = useRef(0);

useEffect(() => {
  let raf;
  const tick = () => {
    progressRef.current = Math.min(1, progressRef.current + 0.01);
    if (elRef.current) {
      elRef.current.style.transform = `translateY(${(1 - progressRef.current) * 24}px)`;
    }
    if (progressRef.current < 1) raf = requestAnimationFrame(tick);
  };
  raf = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(raf);
}, []);

React isn't involved per frame at all now. It renders the element once; the loop moves it.

This is the same rule the engine behind verve follows and it's not a micro-optimisation — it's the difference between a timeline that scrubs smoothly and one that doesn't. React re-renders on discrete events, never on frames.

4. You're using elapsed frames instead of elapsed time

progressRef.current += 0.01;  // ← assumes every frame is the same length

They aren't. A 120Hz display gives you twice the frames, so this animation runs twice as fast. A busy main thread gives you fewer, so it runs slow and then lurches.

requestAnimationFrame hands your callback a timestamp. Use it:

let start = null;
const DURATION = 400;

const tick = (now) => {
  if (start === null) start = now;
  const t = Math.min(1, (now - start) / DURATION);
  apply(t);
  if (t < 1) raf = requestAnimationFrame(tick);
};

Now the animation takes 400ms on every machine, and a dropped frame costs you a frame of smoothness rather than shifting the whole timeline.

The cleanup checklist

Every loop you start has to be stopped, and the ref has to be nulled, not just cancelled:

const rafRef = useRef(null);

useEffect(() => {
  const tick = (now) => {
    // …
    rafRef.current = requestAnimationFrame(tick);
  };
  rafRef.current = requestAnimationFrame(tick);
  return () => {
    if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    rafRef.current = null;
  };
}, []);

Nulling matters because a stale id in the ref makes "is this loop running?" unanswerable, and that question comes up the moment you add a play/pause button.

When to stop writing this

All of the above is fine to own for one loop. Once you have several, or you need springs, or you need to interrupt an animation mid-flight and redirect it, you're rebuilding an animation engine — and the interruption case in particular is genuinely hard to get right by hand.

That's the point where a library earns its bundle, or where designing the motion somewhere else and exporting it saves you from maintaining the loop at all.