When a React dialog unmounts immediately after you set its open prop to false, any exit animation you’ve defined never plays — the element just vanishes. This guide walks you through a simple, reusable pattern that keeps the dialog mounted until its exit transition completes, giving you smooth, professional close animations.
What You’ll Need
- A React project (any version from 16.8 onward, since we use hooks)
- A CSS transition or animation for your dialog’s enter and exit states (e.g., opacity, transform)
- Basic familiarity with React hooks (
useState,useEffect,useRef)
Step-by-Step
-
Create a custom
useDelayedUnmounthook. This hook will return a boolean indicating whether the component should stay mounted, and a function to trigger unmounting after a delay.import { useState, useEffect, useRef } from 'react'; function useDelayedUnmount(delay = 300) { const [shouldRender, setShouldRender] = useState(false); const [isMounted, setIsMounted] = useState(false); const timerRef = useRef(null); useEffect(() => { return () => clearTimeout(timerRef.current); }, []); const mount = () => { clearTimeout(timerRef.current); setIsMounted(true); setShouldRender(true); }; const unmount = () => { setIsMounted(false); timerRef.current = setTimeout(() => setShouldRender(false), delay); }; return { shouldRender, isMounted, mount, unmount }; } -
Integrate the hook into your dialog component. Replace your current
openprop logic with the hook’smountandunmountfunctions. TheshouldRendervalue controls whether the dialog is in the DOM.function Dialog({ open, onClose, children }) { const { shouldRender, isMounted, mount, unmount } = useDelayedUnmount(300); useEffect(() => { if (open) { mount(); } else { unmount(); } }, [open]); if (!shouldRender) return null; return ( <div className={`dialog-overlay ${isMounted ? 'dialog-overlay--open' : ''}`}> <div className={`dialog-content ${isMounted ? 'dialog-content--open' : ''}`}> {children} <button onClick={onClose}>Close</button> </div> </div> ); } -
Define your CSS transitions. The key is to have two states: one for when the dialog is mounted and visible, and one for when it’s mounted but exiting. Use the
isMountedclass to toggle between them..dialog-overlay { opacity: 0; transition: opacity 0.3s ease; } .dialog-overlay--open { opacity: 1; } .dialog-content { transform: translateY(20px); transition: transform 0.3s ease; } .dialog-content--open { transform: translateY(0); } -
Test the exit animation. When you close the dialog,
isMountedbecomesfalse, which triggers the exit transition (opacity fades out, content slides down). After 300ms,shouldRenderbecomesfalseand the dialog is removed from the DOM. - Handle edge cases. If the dialog is unmounted while the exit transition is still running (e.g., the parent component unmounts), the cleanup function in the hook clears the timer to avoid memory leaks.
Common Mistakes
- Using the same delay as the CSS transition duration. If your CSS transition is 0.3s, the delay in the hook must be at least 300ms. If it’s shorter, the dialog will unmount before the animation finishes.
- Forgetting to apply the
--openclass. If you don’t toggle the class based onisMounted, the dialog will always be in the visible state, and the exit transition won’t play. - Relying on
opento control mounting. The whole point is thatopenonly controls the visual state; mounting is controlled byshouldRender. Mixing these up leads to the original problem. - Not cleaning up the timer. If the component unmounts before the timeout fires, you’ll get a warning about setting state on an unmounted component. Always clear the timer in a
useEffectcleanup.
Tips
- Make the delay configurable. Pass the delay as a prop to the hook or the dialog, so you can adjust it per dialog without changing the hook’s code.
- Use a higher-level abstraction. If you have many dialogs, consider creating a
DialogWrappercomponent that encapsulates this logic, so you don’t repeat it. - Consider using a library. If you’re already using a UI library like Material-UI or Chakra, they often have built-in transition components that handle this for you. But if you need a lightweight solution, this pattern works well.
- Test with prefers-reduced-motion. Some users prefer reduced motion. You can check
window.matchMedia('(prefers-reduced-motion: reduce)')and set the delay to 0 in that case, so the dialog unmounts immediately.
Troubleshooting
- The exit animation doesn’t play at all. Check that
isMountedis being toggled correctly. Add a console.log in theuseEffectto see the values. Also, ensure your CSS transition is actually applied — maybe the class name is misspelled or the transition property is wrong. - The dialog unmounts too early. Increase the delay in the hook. Remember, the delay should be equal to or longer than the CSS transition duration.
- The dialog stays mounted forever. If
shouldRendernever becomesfalse, the timer might not be firing. Check that you’re not clearing the timer accidentally, and that theunmountfunction is called whenopenbecomesfalse. - React warning: “Can’t perform a React state update on an unmounted component”. This happens when the timeout fires after the component unmounts. Make sure your cleanup function clears the timer, as shown in the hook.
- The dialog doesn’t appear at all. Ensure
mount()is called whenopenbecomestrue. If you’re using a conditional render elsewhere, remove it and rely solely onshouldRender.
Frequently Asked Questions
Can I use this with CSS animations instead of transitions?
Yes. The pattern works the same way. Instead of a transition, you’d define an animation with a forwards fill mode, and the delay should match the animation duration.
What if my dialog has nested elements that animate separately?
You can apply the same isMounted class to any nested element. Just make sure each element’s transition duration is considered when setting the delay — the delay should be at least the longest duration among them.
Is there a way to avoid the hook and just use CSS?
You could use the animation property with a forwards fill mode and a animationend event listener to unmount the dialog. However, that requires more manual event handling and doesn’t work as cleanly with React’s declarative model. The hook approach is simpler and more reusable.
Does this work with React 18’s StrictMode?
Yes, it does. StrictMode double-invokes effects in development, but the cleanup function clears the timer, so there are no issues. In production, it works as expected.
Can I use this for other components, like tooltips or popovers?
Absolutely. The pattern is generic — any component that needs to animate out before unmounting can use the same hook. Just adjust the delay and the CSS accordingly.
What if the user clicks close multiple times?
The unmount function clears any existing timer before setting a new one, so multiple calls are safe. The dialog will still unmount after the same delay from the last click.
Related
Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.
More cards in this drawer
- Multi-Asset Trading Platforms: Why They’re Essential
- Creality K3 4-Nozzle 3D Printer: 4.8s Swap?
- Notepad Remove Duplicate Lines: 3 Simple Ways
- Storefront Hero Images: Desktop vs. Mobile Versions
- API Security Checklist: A Backend Dev’s Guide
- Device Encryption vs BitLocker: Key Differences