Portals considered harmful
📖 tl;dr: Make sure that each Portal and render() root has its own DOM node. Don't mix the two or try to share roots as this is undefined behavior and leads to brittle apps.
So today I spent a few hours going through various GitHub repos to see how the Portal
component in various virtual-dom Frameworks is used (and abused?) in the wild. It's always a fun thing to do for anyone working on frameworks, because users usually discover new ways of how to use features in ways it wasn't intended by the authors. Sometimes something cool comes out of and sometimes so good that the framework will cater to that use case. It's not all just sunshine and roses though because sometimes using features in unintended scenarios may break stuff. This is the story of the Portal
component.
To recap: A Portal
allows you to jump from the current DOM node to a new container and continue rendering from there. A common use case for that are Modals that we may want to render just before the closing </body>
-tag. Another use case are tooltip components that need to be positioned freely.
Let's imagine we have this HTML:
<body>
<!-- We'll render our app here -->
<div id="app"></div>
<!-- Modals should be rendered here -->
<div id="modals"></div>
</body>
and this accompanying Preact code:
import { render } from "preact";
import { createPortal } from "preact/compat";
const modalContainer = document.getElementById("modals");
function App() {
return (
<div>
Hello
{createPortal(<div>World!</div>, modalContainer)}
</div>
);
}
render(<App />, document.getElementById("app"));
Then the final HTML would look like this:
<body>
<div id="app">
<div>Hello</div>
</div>
<div id="modals">
<div>World!</div>
</div>
</body>
They look nice, feel good to use, but they aren't necessary in most cases.
CSS to the rescue
The modal scenario can be solved without any JavaScript overhead at all. There isn't always a need to take a sledgehammer to crack a nut. Some things can be done with just a tiny bit of CSS:
.my-modal {
position: fixed;
z-index: 200;
}
With positon: fixed
we're creating a new stacking context here and can leverage an additional z-index
property to position the modal above the current visible DOM. This works extremely well! A similar thing can be done for tooltips when they are anchored into the target element's container.
.container {
/* Create new stacking context */
position: relative;
}
.tooltip {
/* position tooltip absolute inside container */
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
}
But with most things web-related there are exceptions to this and the most common one that's referenced is that you have a parent with overflow: hidden
and you need to break out of that container. Fair bit of warning: Before you do any of that, think twice if you really need Portals
or if you can make you're life easier with having a proper CSS hierarchy.
So in those rare cases where Portals are a requirement they do wonders! It's a good tool to have in ones toolbelt, albeit it should be used wisely and with caution.
Why Portals are dangerous
Problems arise when they're used in ways they weren't intended to be used for. Take the following snippet as an example of that:
const root = document.getElementById("root");
const App = () => {
return (
<div>
foo
{createPortal("bar", root)}
{createPortal("baz", root)}
</div>
);
};
render(<App />, root);
Here we have a Preact application that renders into <div id="root"></div>
. So far so good, but now it gets weird: We try to render the word "bar"
into the same container as App
and while we're at it, let's take the biscuit by rendering into the same container again, via a second Portal! In the end we have have three virtual-dom trees battleing for the same DOM container. And it gets worse: The expected outcome of that is not <div>foo bar baz</div>
(spaces added for readability), but instead it is <div>bar baz foo</div>
.
Interestingly, every framework renders this a little different:
- Framework A:
<div>bar baz foo</div>
- Framework B:
<div>bar foo baz</div>
- Framework C:
<div>baz</div>
Even though everyone's result is different, everyone is correct at the same time. We can't fault any of them. So what's happening here? If you put yourself into the shoes of a framework what would you do? Should the framework remove existing DOM? Should it render before or after the existing child nodes?
Framework A
Framework A renders Portals before the App
component is appened to the DOM. It's basically bottom-to-top rendering. We can deduce from the insertion order that this framework has a special branch for dealing with Portals inside an existing tree. Otherwise we'd observe a behavior similar to Framework B where Portals are treated the same way as roots created by render()
.
// 1st Portal
<div id="root">
bar
</div>
// 2nd Portal
<div id="root">
bar
baz
</div>
// App root
<div id="root">
bar
baz
<div>foo</div>
</div>
Framework B
Framework B tries work around any nodes that are already present in the tree. First the App
will be rendered and both Portals take their turn only after App
is finished. It looks like there is no special branching for Portals
and that it shares the underlying semantics with render()
.
This means that the reconciler is aware of any existing nodes and when it inserts the Portals it tries its best to move the nodes around the original content. Whilst this logic is not ideal for Portals, it is advantageous for scenarios where Browser extensions like Google Translate which may insert random DOM nodes into our tree.
// App
<div id="root">
<div>foo</div>
</div>
// 1st Portal
<div id="root">
bar
<div>foo</div>
</div>
// 2nd Portal
<div id="root">
bar
<div>foo</div>
baz
</div>
Framework C
Our last, but arguable the most elegant contender Framework C makes short work of the situation. It simply renders over the existing DOM, thereby removing any child nodes that were present at that time. It's simple, the code is much more elegant and the outcome is predictable. There is no additonal ordering/inserting logic needed. It's kinda the ultimate zen for developers working on frameworks.
// App
<div id="root">
<div>foo</div>
</div>
// 1st Portal
<div id="root">
bar
</div>
// 2nd Portal
<div id="root">
baz
</div>
Err... what about updates?
If you think the above scenario was already tough (it is) and you might be wondering what else is out there, then let me take you right to the endboss of Portals: Rendering literally into each others tree.
function App(props) {
const [i, update] = useState(0);
const ref = useRef();
return (
<div ref={ref}>
foo
{createPortal("bar", root)}
{createPortal("baz", i % 2 === 0 ? root : ref.current)}
<button onClick={() => update(i + 1)}>click</button>
</div>
);
}
render(<App />, root);
At this point we're even deeper in undefined behavior territory. Nobody truly knows what the expected result should be here all frameworks lead to inconsistent results. We can observe the first render and then the second one which changes the order. But starting from there we will never be able to have the same result that we had with our first render. Every render after the first will stay the same. Pretty weird, but to be expected for undefined behavior.
// 1st render
<div id="root">
<div>
bar
baz
foo
<button>click</button>
</div>
</div>
// 2nd render
<div id="root">
<div>
bar
baz
<button>click</button>
foo
</div>
</div>
// 3rd render
<div id="root">
<div>
bar
baz
<button>click</button>
foo
</div>
</div>
// 4th render is the same as 3rd
Ultimatively, this is perfectly fine! This was never an intended use case for Portals. At this point all bets are off and the code depends on multiple features being just right for this scenario. The code depends on the Portal component (obviously), on root detection, stable DOM ordering, unrelated child DOM node detection, etc. The list goes on. On top of that any change in the reconciler has the potential to break that code. It's brittle and we should make our code more resilient.
How to use Portals safely
We've spoken in-depth about undefined behavior surrounding Portals, but what's the right and intended way to use them? It all comes down to ensuring that each root has it's own DOM node. A root created by render()
shouldn't have to share it's container with other Portals and Portals shouldn't have to share them either. And those nodes should be created outside of the framework to ensure that it isn't suddenly removed whilst the Portal tries to re-render into it. That would be like pulling the rug out from under ones feet.
Which brings us to the snippet from the beginning which is coincedentally the one that's usually featured in the documentation of those frameworks.
// HTML:
// <body>
// <div id="app" />
// <div id="modals> />
// </body>
const modals = document.getElementById("modals");
const root = document.getElementById("app");
function App() {
return (
<div>
Hello
{createPortal(<div>World!</div>, modals)}
</div>
);
}
render(<App />, root);
Every Portal, every render()
has it's own DOM node. Nobody renders into each other and no roots have to be shared. It's a good and peacful world. A world that brings us framework developers joy again!