keepcalculator

06 Numbers and Powers

Keep React Dialog Mounted Until Exit Transition Finishes

Card No. 06·56824 · Updated · Standard formula, shown below

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

  1. Create a custom useDelayedUnmount hook. 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 };
    }
  2. Integrate the hook into your dialog component. Replace your current open prop logic with the hook’s mount and unmount functions. The shouldRender value 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>
      );
    }
  3. 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 isMounted class 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);
    }
  4. Test the exit animation. When you close the dialog, isMounted becomes false, which triggers the exit transition (opacity fades out, content slides down). After 300ms, shouldRender becomes false and the dialog is removed from the DOM.
  5. 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 --open class. If you don’t toggle the class based on isMounted, the dialog will always be in the visible state, and the exit transition won’t play.
  • Relying on open to control mounting. The whole point is that open only controls the visual state; mounting is controlled by shouldRender. 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 useEffect cleanup.

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 DialogWrapper component 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 isMounted is being toggled correctly. Add a console.log in the useEffect to 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 shouldRender never becomes false, the timer might not be firing. Check that you’re not clearing the timer accidentally, and that the unmount function is called when open becomes false.
  • 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 when open becomes true. If you’re using a conditional render elsewhere, remove it and rely solely on shouldRender.

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

The whole Numbers and Powers drawer →