Portals and Server-Side Rendering (SSR)
Portals and Server-Side Rendering (SSR)
React Portals have a specific behaviour in SSR environments (Next.js, Remix, etc.):
- Portals cannot render during SSR. The target DOM node (e.g.
document.getElementById('modal-root')) does not exist on the server. Calling it
during SSR will throw an error or returnnull. - On hydration (client-side takeover), Portals render normally into the target DOM node.
Safe SSR Pattern: Check for browser environment
import { createPortal } from 'react-dom';
import { useState, useEffect } from 'react';
function ClientOnlyPortal({ children, selector }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true); // Only true on the client after hydration
}, []);
if (!mounted) return null; // Render nothing on server
const container = document.querySelector(selector);
return container ? createPortal(children, container) : null;
}
// Usage
<ClientOnlyPortal selector="#modal-root">
<Modal />
</ClientOnlyPortal>
Next.js Specific Note
// In Next.js App Router, mark the portal component as a Client Component
'use client';
import { createPortal } from 'react-dom';
// ...Portal code here
Performance Considerations
Portals are lightweight, they do not add significant overhead. However, here are
performance-related points to keep in mind:
- Unnecessary re-renders: The portal component re-renders whenever its parent
re-renders. UseReact.memofor expensive portal content that doesn’t change
often. - Dynamic container creation: Avoid creating and appending a new DOM node on
every render. Create the container once in auseMemoor module-level variable. - Conditional rendering: Return
nullinstead of leaving an
empty Portal mounted when the UI is not visible. This frees React from updating the portal
subtree. - Event listeners: Always clean up event listeners inside
useEffect
return functions to prevent memory leaks when portals unmount.
Stable Container Pattern
import { createPortal } from 'react-dom';
import { useMemo, useEffect } from 'react';
function usePortalContainer(id) {
return useMemo(() => {
let el = document.getElementById(id);
if (!el) {
el = document.createElement('div');
el.id = id;
document.body.appendChild(el);
}
return el;
}, [id]); // Created once per id value
}
function DynamicPortal({ children }) {
const container = usePortalContainer('dynamic-portal');
useEffect(() => {
return () => {
// Optionally remove container when component fully unmounts
// document.body.removeChild(container);
};
}, [container]);
return createPortal(children, container);
}
