Lesson 52 of 80
React removes a component's DOM node the instant it stops rendering it — which is one render too fast for an exit animation. Here's the pattern that buys the extra frame.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Mount animations are easy: the element wasn't there, now it is, so you can animate it in from nothing. Unmount animations have a structural problem — the moment your JSX stops rendering <Toast />, React removes that DOM node immediately. There is no "fading out" a node that no longer exists.
The fix is to stop conflating "should this be visible" with "should this be mounted." You keep the element mounted for one extra beat after it's logically gone, run the exit animation on it, and only unmount it once the animation actually finishes. That means you need a small state machine, not just a boolean: something like entering -> entered -> exiting -> exited, where only exited actually stops rendering the node.
import { useEffect, useState } from "react";
function useExitTransition(isPresent, exitDurationMs) {
// 'entering' | 'entered' | 'exiting' | 'exited'
const [phase, setPhase] = useState(isPresent ? "entered" : "exited");
useEffect(() => {
if (isPresent) {
setPhase("entering");
const id = requestAnimationFrame(() => setPhase("entered"));
return () => cancelAnimationFrame(id);
}
// Caller wants it gone — start the exit animation instead of
// unmounting immediately.
setPhase((prev) => (prev === "exited" ? prev : "exiting"));
const timer = setTimeout(() => setPhase("exited"), exitDurationMs);
return () => clearTimeout(timer);
}, [isPresent, exitDurationMs]);
return phase;
}
function Toast({ isPresent, message, onExited }) {
const phase = useExitTransition(isPresent, 200);
useEffect(() => {
if (phase === "exited") onExited?.();
}, [phase, onExited]);
if (phase === "exited") return null; // only now does it actually unmount
return (
<div className={`toast toast--${phase}`} role="status">
{message}
</div>
);
}
.toast { opacity: 1; transition: opacity 200ms ease; }
.toast--entering { opacity: 0; }
.toast--exiting { opacity: 0; }
The parent stops passing isPresent={true} whenever it wants the toast gone — it never has to touch the timing itself. Toast keeps rendering through exiting and only returns null once phase reaches exited, at which point the parent can safely drop it from its own list.
react-transition-group and Framer Motion's AnimatePresence solve this same problem with more features (interrupted transitions, shared layout animation) — genuinely worth it once you need those. For "fade this thing out before removing it," the state machine above is the whole solution.display: none instead of opacity/transform. display can't be transitioned — it's binary, on or off, with no in-between value for the browser to interpolate. Exit animations need a property that has a range: opacity, transform, max-height.isPresent flips back to true mid-exit. If a toast starts exiting and a new one triggers before the timer fires, without cancelling that stale setTimeout you can get a phase change that stomps the fresh "entering" state a moment later. The effect's cleanup function existing at all is what prevents this.The reason this pattern works is ordinary useEffect cleanup — the same function that cancels a fetch or clears an interval when a component unmounts is here cancelling a stale animation timer when the props change before the previous transition finished. Nothing about animation timing is special-cased by React; you're just using the lifecycle hooks you'd use for anything else, aimed at the DOM instead of a network request.