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 that’s 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 is 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>
<div id="app"></div>
<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 {
position: relative;
}

.tooltip {
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 this is not <div>foo bar baz</div> (spaces added for readability), but instead it’s <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 concurrently. 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 kid nodes?

Framework A

Framework A renders Portals before the App component is appened to the DOM. It is 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().


<div id="root">
bar
</div>


<div id="root">
bar
baz
</div>


<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.


<div id="root">
<div>foo</div>
</div>


<div id="root">
bar
<div>foo</div>
</div>


<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 kid nodes that were present at that time. It is simple, the code is much more elegant and the outcome is predictable. There is no additonal ordering/inserting logic needed. It is kinda the ultimate zen for developers working on frameworks.


<div id="root">
<div>foo</div>
</div>


<div id="root">
bar
</div>


<div id="root">
baz
</div>

Err… what about updates?

If you ponder the above scenario was already tough (it’s) 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.


<div id="root">
<div>
bar
baz
foo
<button>click</button>
</div>
</div>


<div id="root">
<div>
bar
baz
<button>click</button>
foo
</div>
</div>


<div id="root">
<div>
bar
baz
<button>click</button>
foo
</div>
</div>

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 kid 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 is own DOM node. A root created by render() shouldn’t have to share it is 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.

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 is own DOM node. Nobody renders into each other and no roots have to be shared. It is a good and peacful world. A world that brings us framework developers joy again!

Source: https://marvinh.dev/blog/portals-considered-harmful/



You might also like this video