But here's the problem: the dialog component has enter and exit animations, but they aren't playing at all. The reason is that we're rendering it conditionally — which is how we typically load a component lazily when we only want to render it in response to specific actions. For example, I want to show this dialog component only when the user clicks the "Create New Pokémon" button.
We can't remove conditional rendering; otherwise, that chunk will be loaded as soon as the JavaScript file loads. To fix this issue, we need to keep the component mounted and let Headless UI handle the enter and exit animations as well as open and close states.
To do that, we can use a ref to track whether the component has been loaded or not. Here's how we can do it.
18 // To keep track if the component is requested to be loaded
19 const isLoadedRef = useRef(false);
20 const isLoaded = useCallback(
21 () => isLoadedRef.current,
22 []
23 );
24
25 // To trigger the loading of the component
26 const trigger = useCallback(() => {
27 isLoadedRef.current = true;
28 }, []);
29
30 const spinner = createPortal(
31 <Spinner />,
32 buttonRef.current || document.body
33 );
34
35 return (
36 <div>
37 <Button
38 ref={buttonRef}
39 onClick={() => {
40 trigger();
41 setOpen(true);
42 }}
43 >
44 <Plus />
45 Create New Pokémon
46 </Button>
47 <Suspense fallback={spinner}>
48 {isLoaded() && (
49 <CreatePokemon
50 open={open}
51 onClose={() => setOpen(false)}
52 />
53 )}
54 </Suspense>
55 </div>
56 );
57}
The end result will look like this.
Bam! The transition is now working — but how?
Initially, the isLoadedRef is set to false, so the first render returns JSX that looks like this.
1<Suspense fallback={spinner}>
2 {false && (
3 <CreatePokemon
4 open={open}
5 onClose={() => setOpen(false)}
6 />
7 )}
8</Suspense>
Now, when we click the “Create New Pokémon” button, we set isLoadedRef to true, and then update the open state using setOpen. This triggers a re-render, and on the next render, React receives new JSX that looks like this.
1<Suspense fallback={spinner}>
2 {true && (
3 <CreatePokemon
4 open={open}
5 onClose={() => setOpen(false)}
6 />
7 )}
8</Suspense>
Here, React sees that a new component needs to be mounted, so it loads the CreatePokemon. Since this is the first time the component is requested, the bundler downloads the CreatePokemon bundle along with all it's dependencies and loads them for us. During this process, the component is suspended, so the Suspense boundary shows the fallback UI. Once all the bundles are download and parsed, we can finally see our sweet Pokémon dialog appear.
Now, if we close and open the dialog again, we can see that the transition work correctly. The reason is that every time the open state changes, the resulting JSX remains the same as before, since isLoadedRef is now always true. This means the component stays mounted (i.e the condition is always true, and it always returns CreatePokemon rather then false), allowing Headless UI to handle the enter and exit animations properly.
We can take this a step further. We can eagerly load the component when the user hovers over or focuses on the button. This improves the experience, because by the time the user clicks the button, the component is already loaded and ready to be displayed.
It is okay to call dynamic import multiple times. The bundler likes webpack or vite, or browser if we use native ESM import, they maintain a cache of all the promises that are created for these dynamic imports and resolved values of these promises. So, it doesn't matter if we call the dynamic import multiple times; it will only download and parse the module once. After that, it will return the cached resolved value of the promise.