React Portals: the three use cases that actually matter
I understood what React Portals were for two years before I actually needed one. Then I built a tooltip inside an overflow:hidden container, watched it get clipped, and remembered portals existed. They solve a specific class of problems — here are the three cases where they are genuinely the right tool.
What createPortal does
import { createPortal } from 'react-dom';
function MyPortal({ children }: { children: React.ReactNode }) {
// Renders children into document.body, not into MyPortal's parent DOM node
// But children are still in MyPortal's React tree (events bubble, context works)
return createPortal(children, document.body);
}
Use case 1: Tooltips inside overflow:hidden containers
This is the most common portal use case. A tooltip inside a overflow: hidden table cell gets clipped. Portaling it to document.body escapes the clip:
import { useState, useRef, createPortal } from 'react';
function Tooltip({
children,
content,
}: {
children: React.ReactNode;
content: string;
}) {
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const triggerRef = useRef(null);
function showTooltip() {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + window.scrollY + 8,
left: rect.left + window.scrollX + rect.width / 2,
});
setVisible(true);
}
return (
<>
setVisible(false)}
>
{children}
{visible && createPortal(
{content}
,
document.body
)}
>
);
}
Use case 2: Modals that need to overlay everything
import { createPortal, useEffect } from 'react';
function Modal({
isOpen,
onClose,
children,
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
// Prevent body scroll while modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
{
if (e.target === e.currentTarget) onClose();
}}
>
{children}
,
document.body // Renders at top level, overlays everything
);
}
Use case 3: Notification toasts from anywhere in the tree
// context/toast.tsx
import { createContext, useContext, useState, createPortal } from 'react';
interface Toast {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
const ToastContext = createContext<{
addToast: (message: string, type?: Toast['type']) => void;
} | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState([]);
function addToast(message: string, type: Toast['type'] = 'info') {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}
return (
{children}
{/* Toast container portaled to document.body */}
{createPortal(
{toasts.map((toast) => (
{toast.message}
))}
,
document.body
)}
);
}
export const useToast = () => {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
};
// Usage anywhere in the tree:
function SomeComponent() {
const { addToast } = useToast();
return (
);
}
The key property: React tree vs DOM tree
Events from portal children still bubble through the React component tree, not the DOM tree. This means a click inside a portaled modal will bubble to the modal's parent component in React — which is usually what you want for event handling and context access.
If you find yourself reaching for portals for other use cases, check if a simpler solution exists first. Portals add complexity. But for these three cases — tooltips in clipping containers, modals, and global notifications — they are the cleanest solution available.