Reusable React Portals
A story about the React lifecycle and side-effects
The React docs say this about Portals:
Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component.
This means that they are the tool to use when we need to render parts of our UI in a different layer. What makes them awesome it’s how portals behave:
Even though a portal can be anywhere in the DOM tree, it behaves like a normal React child in every other way. Features like context work exactly the same regardless of whether the child is a portal, as the portal still exists in the React tree regardless of position in the DOM tree.
A far as the API goes, using a portal is pretty straightforward. Providing that you have a
div#portal in your HTML template, you just use
Quite simple. However, we are talking React here and, when using React, we tend to build reusable components (to encapsulate low level details, like finding the root DOM node).
Our current Portal component
Portal component allows us to render stuff inside a portal. It creates the portal container in the DOM on demand and remove it when is no longer needed. Also we can share the container between different portals.
There are other different approaches to this out on the wild with different features: some don’t allow sharing, others use state instead of refs and render the portal in two steps, etc.
Try now this example:
You’ll notice that, while we share containers between portals, there is always a wrapper div surrounding the portal children 😔.
There’s a reason for this. Bear with me.
Trying to avoid the wrapper
Let’s try not to use the wrapper. Just simplify the component and do what naturally comes to mind:
This should work. Let’s see what happens if we show and hide our portals several times…. the cleanup fails! 😅
What the hell!
You can take a long hard look at the code and have a difficult time spotting the problem. After a little debugging you’ll get to see that at the time we execute our
useEffect cleanup the container still contains the old child nodes 🤨.
But… shouldn’t the DOM be updated according to the docs:
The function passed to
useEffectwill run after the render is committed to the screen. Think of effects as an escape hatch from React’s purely functional world into the imperative world.
The function passed to
useEffectfires after layout and paint, during a deferred event.
It seems that the new DOM has already been created when the cleanup effect is run, but it it’s not committed yet. Checking the DOM as we did yielded obsolete results 😔.
And, that’s why the wrapper version works. By using a detached wrapper, the container/children relation is not subject to the React lifecycle. It doesn’t matter that the wrapper still contains old DOM nodes. We can detach it from the container and check if there are any attached wrappers left before React cleans up the node contents.
But, as I said before, this shouldn’t happen. In fact, using the experimental version of React it works as expected. This smells like a problem with how React fibers work. Maybe it’s something portal related only 🤔. To get to the bottom it’s mandatory to dig deeper into the React codebase (or wait for someone to enlighten us in the comments 😇).
A possible solution
If the problem is that the DOM is not committed when we do our cleanup, maybe we can wait a little longer and allow React to really flush the DOM. Let’s schedule our cleanup for later using requestAnimationFrame.
No wrapper and the cleanup works as expected.
We have mixed feeling about this solution. We are not sure we would use it in production code. We are cheating and we are making too many assumptions about how React works internally. Would this work with the new concurrent mode? It does now. Would this work in future React versions? Who knows 🤷🏽♀️.
What seemed an easy task: creating a reusable
Portal component, was harder than we thought.
Toying with this component reminded us that it’s fundamental to know how React works under the hood. Make no assumptions and try not to bend React too much to avoid problems.