Skip to content

Making sure React renders

Author's photo
2 min read ·

So I've been fixing a problem of enter transitions being sometimes skipped in my React Promise Modal library — I've covered the library in my previous posts.

The problem

The idea behind the library is to make it super easy to implement and spawn modals while having transitions supported out of the box. The API for making your modals appear and disappear in style is absolutely straightforward.

For this, every time a modal has to appear the library first renders it "offscreen" (with the show flag set to false) — letting the DOM fully render and be present in the document. Right after that the second render happens with show: true — and that's where the CSS transitions do the actual visual animation, animating it from A → B.

The tricky part is to make sure that the initial "offscreen" render really happens. If it's skipped, there is no A → B, and it just appears in the final B state.

Since React 18 createRoot change, React started to automatically batch multiple consequent state changes into a single render — that's when the old implementation has stopped working completely.

What doesn't work

My initial workaround ideas were:

  1. Adding a setTimeout delay before switching to the next stage with setStage() state setter.
  2. Triggering the next setStage() switch from withing a useEffect() after the previous stage has been "rendered".
  3. Same as above, but using useLayoutEffect().

Relying on useEffect() did not work, as it runs before paint for interaction-caused updates.

And useLayoutEffect() runs after React commits the DOM, but before the browser repaints it. State updates triggered from useLayoutEffect() are processed before the repaint as well.

So none of it worked. React would still swallow a render seeing more state changes queued in the pipeline. Which is actually a good thing. But we have to find another solution.

The solution

The real solution was to rely on the browser's requestAnimationFrame() API — as it is exactly designed to execute arbitrary code in a separate animation frame, making sure the previous frame rendering had been already finished and fully painted on screen.

This simple useNextFrameEffect() hook combines the useEffect() semantics with the requestAnimationFrame() API, providing an easy-to-use abstraction:

type CleanupFn = () => void;

export function useNextFrameEffect(
    callback: () => CleanupFn, 
    deps: unknown[],
): void {
    const stableCallback = useEffectEvent(callback);

    useEffect(() => {
        let cleanup = noop;
        const frame = requestAnimationFrame(() => {
            cleanup = stableCallback();
        });

        return () => {
            cancelAnimationFrame(frame);
            cleanup();
        };
    }, deps);
}

And then you can use it to implement the next-step state switch in an effect:

useNextFrameEffect(() => {
    if (stage === Stage.MOUNTED) {
        setState(Stage.OPENING);
    }
    // ... 
}, [stage]);

Have you encountered a similar problem? Let me know via social networks or on LinkedIn.

Cheers! 🖖

End of article