Portals and Suspense
Portals for rendering outside the DOM tree and Suspense for loading states
Portals with createPortal
Portals render children into a different DOM node while preserving React context.
"use client"
import { createPortal } from "react-dom"
interface ModalProps {
open: boolean
onClose: () => void
children: React.ReactNode
}
function Modal({ open, onClose, children }: ModalProps) {
if (!open) return null
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative z-10 rounded-lg bg-white p-6 shadow-xl">
{children}
</div>
</div>,
document.body
)
}
Why portals?
- Modal overlays that shouldn't inherit
overflow: hiddenfrom a parent - Tooltips that need to break out of a clipped container
- Dropdowns that render over other elements
Event propagation still follows React tree, not DOM tree. Click inside a portal bubbles up through React ancestors, not DOM ancestors.
Lazy Loading with Suspense
Split large bundles into chunks loaded on demand.
React.lazy
import { lazy, Suspense } from "react"
// This file is loaded only when rendered
const HeavyChart = lazy(() => import("./HeavyChart"))
function Dashboard() {
return (
<Suspense fallback={<div className="h-64 animate-pulse bg-gray-200" />}>
<HeavyChart />
</Suspense>
)
}
Wrapping multiple lazy components
You can nest Suspense boundaries to load parts independently:
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<SidebarSkeleton />}>
<LazySidebar />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<LazyChart />
</Suspense>
</div>
)
}
Each boundary is independent — the sidebar renders while the chart loads.
useTransition for Non-Urgent Updates
Mark state updates as non-urgent so the UI stays responsive.
"use client"
import { useState, useTransition } from "react"
function SearchPage() {
const [query, setQuery] = useState("")
const [results, setResults] = useState<string[]>([])
const [isPending, startTransition] = useTransition()
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value
setQuery(value) // urgent — keep input responsive
startTransition(() => {
// non-urgent — can be interrupted
const filtered = searchItems.filter((item) =>
item.toLowerCase().includes(value.toLowerCase())
)
setResults(filtered)
})
}
return (
<div>
<input value={query} onChange={handleChange} placeholder="Search..." />
{isPending && <p className="text-gray-400">Updating...</p>}
<ul>
{results.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
</div>
)
}
isPending is true while the transition is pending. The old results stay visible.
See It In Action: Portal Modal with Lazy Load
1import { useState } from "react"2import { createPortal } from "react-dom"3 4function Modal({ onClose, children }: { onClose: () => void; children: React.ReactNode }) {5return createPortal(6 7<div style={{8 position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)",9 display: "flex", alignItems: "center", justifyContent: "center",10 }}>11<div style={{ background: "white", padding: 24, borderRadius: 8, minWidth: 300 }}>12{children}13<button onClick={onClose}>Close</button>14</div>15</div>,16document.body17)18}19 20function App() {21const [open, setOpen] = useState(false)22return (23 24<>25<button onClick={() => setOpen(true)}>Open Modal</button>26{open && <Modal onClose={() => setOpen(false)}><p>Rendered via portal</p></Modal>}27</>28)29}The Modal uses createPortal to render into document.body - escaping parent overflow or z-index constraints. React context still flows through normally.