
Here's the simple solution everyone is missing when implementing exit animations in React components
I recently came across Max Barvian’s article about fixing exit animations in React. He built a clever solution using Suspense to “freeze” components during their exit animations, preventing visual flashes when state updates.
It’s genuinely impressive work. The kind of deep React knowledge that makes you go “wait, you can do that?” I was super intrigued by this pattern, but something didn’t sit right with me, and when this happens I ask myself the most important question in software engineering:
Do you really need to do all that?
Let’s start with what we’re actually trying to fix. When you close a dropdown or select component in React, you often see this:
That flash is distracting. You’re watching something exit, but it suddenly changes content mid-animation. Your brain notices.
Here’s what causes it:
function Dropdown({ isOpen, onClose }) {
const [selectedValue, setSelectedValue] = useState('');
const handleSelect = (newValue) => {
setSelectedValue(newValue); // State update triggers re-render
onClose(); // Animation starts, but too late
};
return (
<AnimatedDropdown isOpen={isOpen}>
<div>Selected: {selectedValue}</div>
{/* Options render with NEW value visible during exit */}
</AnimatedDropdown>
);
}React batches both updates together. The dropdown shows the new selected value while it’s animating closed. Not ideal.
Max’s solution is to hijack Suspense’s behavior. Here’s the simplified version of what he built:
function Freeze({ frozen, children }) {
const elementsRef = useRef(new Set());
// Fragment ref to track DOM nodes
const fragmentRef = (frag) => {
if (!frag) return;
const observer = new ElementsObserver(elementsRef);
frag.observeUsing(observer);
return () => frag?.unobserveUsing(observer);
};
// Override Suspense's display: none
useInsertionEffect(() => {
if (!frozen) return;
elementsRef.current.forEach((element) => {
element.style.display = '';
});
}, [frozen]);
return (
<Fragment ref={fragmentRef}>
<Suspense>
{frozen && <Suspend />}
{children}
</Suspense>
</Fragment>
);
}
// Custom observer class
class ElementsObserver {
constructor(elementsRef) {
this.elementsRef = elementsRef;
}
observe(element) {
this.elementsRef.current.add(element);
}
unobserve(element) {
this.elementsRef.current.delete(element);
}
disconnect() {
this.elementsRef.current.clear();
}
}
// Infinite promise to trigger suspension
const infinitePromise = new Promise(() => {});
function Suspend() {
use(infinitePromise);
return null;
}Then you use it like this:
<Select>
<Button>
<SelectValue />
</Button>
<Popover>
{({ isExiting }) => (
<Freeze frozen={isExiting}>
<ListBox>{/* ... */}</ListBox>
</Freeze>
)}
</Popover>
</Select>Does this work? Yes. Absolutely. It’s clever as hell.
Is it a good idea? I’m not so sure it actually is.
Here’s what’s happening under the hood:
display: none !importantuseInsertionEffect to undo the display: noneIt’s a hack. An elegant hack, but still a hack. And it comes with baggage:
If you’re maintaining this code six months from now, you’re going to have questions. If React changes Suspense internals, you’re going to have problems.
useRefHere’s the thing everyone seems to forget: useRef doesn’t trigger re-renders.
That’s literally its job. Hold a value between renders without causing updates. Perfect for this exact scenario.
function Dropdown({ isOpen, onClose }) {
const pendingValueRef = useRef(null);
const [selectedValue, setSelectedValue] = useState('');
const handleSelect = (newValue) => {
// Store in ref instead of state
pendingValueRef.current = newValue;
onClose(); // Start closing animation
};
const handleAnimationEnd = () => {
// After animation completes, commit to state
if (pendingValueRef.current) {
setSelectedValue(pendingValueRef.current);
pendingValueRef.current = null;
}
};
return (
<AnimatedDropdown
isOpen={isOpen}
onAnimationEnd={handleAnimationEnd}
>
<div>Selected: {selectedValue}</div>
{/* Options render with OLD value during exit */}
</AnimatedDropdown>
);
}That’s it. No custom components. No Suspense hijacking. No infinite promises. No observer classes.
1. It’s readable
You can scan this code and immediately understand what’s happening:
2. It uses React as intended
useRef is designed for holding values between rendersonAnimationEnd is designed for running code after animations3. It works with existing libraries
This pattern works immediately with:
No wrapper components needed. No “freezing” logic to inject.
4. It’s future-proof
React can change Suspense internals however they want. Your useRef will keep working, because you’re using documented APIs as intended.
What about interruptions? User closes the dropdown, then reopens before animation finishes?
const handleAnimationEnd = () => {
// Only commit if we're actually closed
if (!isOpen && pendingValueRef.current) {
setSelectedValue(pendingValueRef.current);
pendingValueRef.current = null;
}
};What if you need to track multiple pieces of state?
const pendingStateRef = useRef(null);
const handleSelect = (newValue, newCategory) => {
pendingStateRef.current = { value: newValue, category: newCategory };
onClose();
};
const handleAnimationEnd = () => {
if (pendingStateRef.current) {
const { value, category } = pendingStateRef.current;
setSelectedValue(value);
setSelectedCategory(category);
pendingStateRef.current = null;
}
};Still straightforward. Still readable.
To be fair to Max’s solution, there are scenarios where the Freeze component could be useful:
1. You don’t control the component library
If you’re using a library that doesn’t expose animation callbacks, and you can’t modify it, wrapping in Freeze might be your only option.
2. You need to freeze computed/derived state
If you’re using React Aria Components with hover/focus states that derive from React state (not CSS pseudo-classes), the Freeze approach handles this automatically.
3. You have deeply nested state updates
If closing the component triggers state updates in multiple child components, tracking all that in refs could get messy. Freeze handles it at the boundary.
But for most cases the useRef pattern is simpler, more maintainable, and easier to reason about.
Just because you can do something clever doesn’t mean you should.
Max’s solution demonstrates impressive React knowledge. But impressive isn’t always practical. When you’re maintaining a codebase with multiple developers over months or years, simple wins.
Ask yourself:
Most of the time, the boring solution is the right solution.
Use useRef. Use animation callbacks. Keep it simple.
References: