Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component. - React Docs
In simple terms - portals allow you to render the component’s content into an element that’s outside of where the component’s content is rendered normally.
In the real world, they allow us to implement tooltips, dropdowns, modals, and overlays that are not affected by positioning and overflow rules of other elements.
In this post I will:
- Explain react portals and show a basic react portal example;
- Show an example of a tooltip with a problem that portals help to solve;
- Show an example of a tooltip implemented using portals.
How do React portals work?
Let’s say we have a yellow disc and two boxes - blue and green:
If we wanted to add the yellow disc into the blue box, the simplest way to do that is to have the blue ball render the disc:
function BlueBox() {
return (
<div
style={{
width: 100,
height: 100,
background: "blue"
}}
>
<YellowDisc />
</div>
);
}
function App() {
return (
<div>
<BlueBox />
<GreenBox />
</div>
);
}
The blue box now owns the yellow disc.
Let’s say that the green box wants to borrow the yellow disc, but the blue box won’t simply let the green box render it. The blue box wants to “own” the disc and stay informed about what happens with it, but it will allow the green box borrow it.
Portals allow the blue box to render the yellow disc into the green box, without the green box owning the yellow disc:
Here’s what happened:
- I gave the green box an
id
attribute so that I could query the DOM element for the portal to render into. - The blue box uses the portal to render the yellow disk inside the green box.
- I used the state to keep track of the reference to the green box and
useEffect
to find the element after both boxes have been rendered. If I tried to find the green box by id outside ofuseEffect
, then I might not find it. Depending on the nesting and element order, the green box might not be rendered yet. However, in the real world, we usually render intodocument.body
which always exists and we can query it directly without theuseEffect
.
React event propagation using portals
Well, rendering the element is not something special. In fact, you may achieve the same result without using portals, by using refs.
However, portals give us one advantage - they allow the React events to bubble up the React component tree to their parent React elements.
This is what allows the blue box to keep tabs on the events from the yellow disc, even though it’s rendered inside of the green box.
In fact, to observe this behavior, add onClick={() => console.log("blue clicked!")}
to the blue box div
and onClick={() => console.log("green clicked!")}
to the green box div
.
You will notice that the click event on the yellow disc bubbles up and triggers a click event handler on the blue box. But it doesn’t trigger it on the green box. That’s what I mean when I say that the blue box owns the yellow disc.
See it in action here:
If you inspect the DOM now, you will see that the green box now contains the disc even if the blue box owns it:
How come the blue box receives the events and not the green box?
There is no magic here. This is simply because React uses its own event system which works in parallel with the DOM event system. React events bubble up the React component tree, and DOM events bubble up the DOM tree.
What problems do portals solve?
At this point, you may understand how portals work, but how do you know when to use them?
You won’t need to use portals often. If you’re a beginner, you most likely won’t need to use them at all for a long time. Even in the above example, if we just let the green box render the circle, we’d have no problems. However, there are some cases where portals can be life-savers.
Tooltips in the real world
Let’s say you’re building a tooltip:
If you hover the button, you get a tooltip below that says Clicky clicky
. It’s basic but works fine. That’s how I would start out building a simple tooltip.
However, what if at some point we need to use it in a container, that hides the overflowing content? To see the problem, add style={{ height: 35, overflow: "hidden" }}
to the div
in App
component. Our tooltip gets cut off.
That’s a real-world issue that portals can help us solve. Also, you may have noticed that the tooltip component wraps its children with a relatively positioned div. This can make it difficult to use such tooltips in practice because you’d need to put in extra effort to ensure they don’t mess up the layout.
See it in action:
How do portals help to implement tooltips?
The general way to solve the clipping issue is:
- On hover, determine the anchor point for the tooltip on-screen, normally using
getBoundingClientRect
oroffsetLeft
andoffsetTop
properties on the element. - Render the tooltip as close to
<body>
element as possible. - Use
fixed
position on the tooltip and position it near the anchor element using the anchor point coordinates we got previously.
Whoa, this quickly got a lot more complex. And because the tooltip is now a free-floating element on the screen, we may need to handle edge cases, such as:
- The tooltip position may need to update when we scroll;
- The tooltip should re-position itself near the screen edges if it goes out of the screen.
- The tooltip should work well with other free-floating elements such as modals.
None of these issues are trivial to solve, that’s why we have libraries whose sole purpose is to manage the positioning of floating elements, like react-laag and popperjs. In fact, you’re probably better off not building a custom tooltip solution of your own. Been there, done that…
Here’s what such a tooltip implementation may look like in the real world:
Here’s what I did:
- I’ve replaced
isVisible
with the tooltip’s position on screen. If the position is set, it means the tooltip is visible, otherwise, it’s hidden. - I changed the hover event handler to get the button’s height and position on the screen. Then I use them to place the tooltip at the bottom edge of the button.
- I assigned the event handlers to the anchor, by cloning it first. This allows me to remove the
div
wrapper from the tooltip, so it doesn’t affect the layout in any way. You could leave the wrapper and attach the event handlers to it , but I don’t like it when commonly used components, such as tooltips, make me think about how they will affect layouts. - Finally, I used a React portal to render the tooltip into
document.body
, so that it’s not affected by the parent elements’ layout rules. Then I changed its position tofixed
, and set its coordinates on the screen to be at the bottom of the button.
If you test it now, you will see that the tooltip works exactly the same, but it’s not affected by the overflow: hidden
that we set on the button’s container div
. If you implemented dropdowns, modals, and toasts in a similar fashion, they would also be unaffected by the layout rules.
However, it is important to realize the tradeoffs of this implementation.
- The code is now harder to understand.
- We need to do more work because we need to sync the position of the tooltip with its anchor element. It can be hard:
- If the anchor or the tooltip is animated or changes sizes frequently.
- When we scroll the page we should reposition or hide the tooltip.
- If the tooltip is near the edge of the screen, we may want to adjust its placement to stay inside the screen.
Here’s the final implementation in action:
Why is position: 'fixed' not enough?
Since the tooltip already has a fixed position, it's relative to the viewport, no matter where it is in the DOM tree, right?
Well, yes and no. In simple apps, you should have no problems if you set position: 'fixed'
even if you don't use portals. However, in the real world you will encounter unexpected and confusing issues.
It is common to use transform: translate(-50%, -50%)
to anchor fixed elements to their center, for instance when building a modal. However, transforms create a new container for fixed elements, so you may run into issues like this:
Another issue you will face is if you have other fixed
elements with a z-index
that are higher in the DOM tree. For instance, even if your tooltip has z-index: 9999
if it's parent container is fixed
with z-index: 1
, the parent's sibling with position: fixed
and zIndex: 2
will always stay visible:
You can avoid all of these issues simply by using portals to render the tooltip into the body element.
Conclusion
The problems and solutions, where we may apply React portals, follow a similar pattern:
- An element is affected by the parent element’s positioning or overflow rules.
- Using portals, we render the component in another place, where it’s no more affected by those rules.
Here are some common real-world use cases for portals: tooltips, dropdowns, modals, overlays, and toasts.
Portals often make the implementation more complex and introduce edge cases, so I would only use them if no simpler method is available. Honestly, I don’t see much wrong with the original implementation of the tooltip in my example, and I would start with that.
In the real world, when faced with the above use cases, it’s probably simpler to use a reliable UI component for modal, dropdown, or tooltip instead of building your own.
Nevertheless, it’s a worthwhile exercise to try to build your own implementation to get familiar with the problems. Solving them will make you a better developer, no matter what skill level you’re at.
Though it’s probably best if you do that in your own sandbox project…
If you want to master Promises, the best way to do that is by implementing them from scratch. Read this next:
Let me know in the comments - what React feature trips you up?