Photo by Zoltan Tasi on Unsplash

Using React Portals for cleaner components

Osman Cea
5 min readOct 15, 2023

In this article I want to share a simple technique using React Portals for better decoupling of context specific features that live on global contexts.

If that was a mouthful, a quick example will make it clearer.

Lets say we have an application where the header can have a contextual menu, some buttons, links and other things that depend on the page that the user is visiting. Our app structure could look like this:

import React from 'react';

export const App = () => {
return (
<main>
<AppHeader />
<AppRouter>
<Route path="/">
<Home />
</Route>
<Route path="/dashboard">
<Dashboard />
</Route>
<Route path="/me">
<Profile />
</Route>
</AppRouter>
</main>
);
};

Where the AppHeader component is in charge of rendering all of these things:

import React from 'react';

const AppHeader = () => {
const handleSomething = () => {};
const handleSomethingElse = () => {};

return (
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/dashboard">Dashboard</Link>
<Link to="/me">Profile</Link>
</nav>
<ContextMenu
options={[
{ label: 'Option 1' },
{ label: 'Option 2' },
]}
/>
<Button onClick={handleSomething}>Do something</Button>
<Button onClick={handleSomethingElse}>Do something else</Button>
</header>
);
};

If some of these components have to be rendered conditionally, then we’ll have to put all of that presentation logic in the AppHeader component and the corresponding state that controls that logic on a parent component, like the App component, so it can be accessed from anywhere in the application.

import React from 'react';

const AppHeader = ({
isDoSomethingEnabled,
isDoSomethingElseEnabled,
menuOptions,
onDoSomething,
onDoSomethingElse,
}) => {
return (
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/dashboard">Dashboard</Link>
<Link to="/me">Profile</Link>
</nav>
{menuOptions.length > 0 && (
<ContextMenu options={menuOptions} />
)}
{isDoSomethingEnabled && (
<Button onClick={onDoSomething}>Do something</Button>
)}
{isDoSomethingElseEnabled && (
<Button onClick={onDoSomethingElse}>Do something else</Button>
)}
</header>
);
};

And in the App component:

import React from 'react';

export const App = () => {
const [menuOptions, setMenuOptions] = React.useState([]);
const [isDoSomethingEnabled, setIsDoSomethingEnabled] = React.useState(false);
const [isDoingSomething, setIsDoingSomething] = React.useState(false);
const [
isDoSomethingElseEnabled,
setIsDoSomethingElseEnabled,
] = React.useState(false);

const handleDoSomething = async () => {
setIsDoingSomething(true)

await doSomething();

setIsDoingSomething(false);
};
const handleDoSomethingElse = () => {};
const handleSetMenuOptions = options => setMenuOptions(options);

return (
<main>
<AppHeader
isDoSomethingEnabled={isDoSomethingEnabled}
isDoSomethingElseEnabled={isDoSomethingElseEnabled}
menuOptions={menuOptions}
onDoSomething={handleDoSomething}
onDoSomethingElse={handleDoSomethingElse}
/>
<AppRouter>
<Route path="/">
<Home />
</Route>
<Route path="/dashboard">
<Dashboard />
</Route>
<Route path="/me">
<Profile />
</Route>
</AppRouter>
</main>
);
};

So now we face an scenario where we’ll have to pass some of this state and callbacks to our routed components as props. We’ll also have to set and unset some of these properties when entering and leaving the page:

import React from 'react';

const Profile = ({ isDoingSomething, onSetMenuOptions, onSetIsDoSomethingEnabled }) => {
React.useEffect(() => {
onSetMenuOptions([
{ label: 'Option 1' },
{ label: 'Option 2' },
]);
onSetIsDoSomethingEnabled(true);

// Don't forget to reset the state when we leave so these elements
// do not leak when another page is rendering
return () => {
onSetMenuOptions([]);
onSetIsDoSomethingEnabled(true);
};
}, [onSetMenuOptions]);

return (
<div>
{isDoingSomething && (
<p>Hold on! There is something being done</p>
)}
{/* ... */}
</div>
);
};

We would have to do something similar in every page where we want to change the options, enable a button or do anything related to the content of the header, which is going to get harder to maintain as the application grows and more pages are added.

There’s also another problem and it’s related to the handlers that we’re passing to our buttons, for example handleDoSomething. If handleDoSomething changes any state that is relevant to one of our pages, we would have to keep that state in the App component.

Of course some of these issues can be solved with proper state modeling or by having better encapsulation. Using a proper state management solution would also aid these problems. Even just decoupling the state from the component tree, with something like Context would do the trick.

However, the underlying problem remains intact, and it’s that the content of the AppHeader component is tied to the rendering cycle of Profile or to any other component that would need to change these behaviors.

So what we want to do is to follow the principle of colocation:

Place code as close to where it’s relevant as possible

And this means to put the contents of AppHeader in the Profile component. But how can we achieve this?

Using Portals

If you don’t know what a Portal is, go read the docs.

So what we want to do is to decouple the dynamic contents of AppHeader from itself, and for this we’re going to create an outlet where other components can have control of what they want to render:

import React from 'react';

export const APP_HEADER_OUTLET_NAME = "APP_HEADER_OUTLET";

export const AppHeaderOutlet = () => <div id={APP_HEADER_OUTLET_NAME} />;

We’re going to put this outlet in the AppHeader component:

import React from 'react';
import { AppHeaderOutlet } from './app-header-content';

const AppHeader = () => {
return (
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/dashboard">Dashboard</Link>
<Link to="/me">Profile</Link>
</nav>
<AppHeaderOutlet />
</header>
)
}

Now, we want to have another component that we can render on the Profile page that is going to project its contents to AppHeader. This is where portals come into play:

import React from 'react';
import { createPortal } from 'react-dom';

export const APP_HEADER_OUTLET_NAME = "APP_HEADER_OUTLET";

export const AppHeaderOutlet = () => <div id={APP_HEADER_OUTLET_NAME} />;

export const AppHeaderContent = ({ children }) => {
return createPortal(
children,
document.getElementById(APP_HEADER_OUTLET_NAME);
);
}

AppHeaderContent is creating a portal that is going to be rendered wherever we place AppHeaderOutlet. This is super useful for our use case, as now we can put everything related to the contents of AppHeader within the Profile component itself:

import React from 'react';
import { AppHeaderContent } from './app-header-content';

const Profile = () => {
const [isDoingSomething, setIsDoingSomething] = React.useState(false);

const handleDoSomething = async () => {
setIsDoingSomething(true)

await doSomething();

setIsDoingSomething(false);
};

return (
<div>
<AppHeaderContent>
<ContextMenu
options={[
{ label: 'Option 1' },
{ label: 'Option 2' },
]}
/>
<Button onClick={handleSomething}>Do something</Button>
</AppHeaderContent>

{isDoingSomething && (
<p>Hold on! There is something being done</p>
)}

{/* ... */}
</div>
);
};

And now the rendering of the children of AppHeaderContent are tied to the rendering lifecycle of the Profile component, so there’s no need for us to maintain extra state to render the button or to set the options of ContextMenu. We don’t need to do any cleanup as well, because when the Profile component is unmounted, AppHeaderContent will be unmounted as well, hence destroying whatever it was projected into AppHeaderOutlet.

And that’s all folks. I hope you find a use for this technique. If you liked the content, don’t forget to share it!

You can find me on Twitter @daslaf.

--

--