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.