Skip to content

Render outside the tree with Portal

Toasts, modals, and tooltips need to escape their ancestors — an overflow: hidden wrapper, a transform that creates a new containing block, or a z-index stacking context that traps them. The fix is to render the content into a DOM node somewhere else (usually a sibling of your app root) while keeping it logically a child of the component that owns it.

<Portal> is Reactra’s thin wrapper over react-dom’s createPortal that does exactly that.

Portal ships from @reactra/router (the same package as RouteLink / RouteRenderer). It is a plain, hand-imported React component — you import and use it yourself; the compiler never emits it. That keeps the router’s compiler-emitted code react-dom-free; react-dom is a peer dependency you already have in any Reactra app.

import { Portal } from "@reactra/router"

With no to, children mount into document.body:

<Portal>
<div className="toast">Saved.</div>
</Portal>

The <div className="toast"> becomes a direct child of <body>, free of any ancestor’s overflow/transform/stacking context — but events still bubble through the React tree as if it were rendered inline, and it still re-renders with its owner.

Pass to as a CSS selector string or a live DOM Element.

// Selector — resolved via document.querySelector
<Portal to="#toast-root">
<Toast>Copied to clipboard</Toast>
</Portal>
// Element — when you already hold the node
<Portal to={overlayHostRef.current!}>
<Dialog />
</Portal>

You provide the target node yourself (e.g. <div id="toast-root" /> next to your app root in index.html). <Portal> does not create or remove mount nodes — point it at a node that already exists.

If the selector matches nothing, <Portal> does not throw and does not drop your content. It:

  1. renders the children inline (un-portaled) so they still appear, and
  2. logs one console.warn naming the missing selector.

That warning is unconditional — it is not gated behind a dev/prod env check — because a missing portal target is an immediate wiring mistake you want to see the instant it happens, in any environment.

// #toast-root doesn't exist in the document:
<Portal to="#toast-root">Hi</Portal>
// → "Hi" renders inline where <Portal> sits, and the console shows:
// [@reactra/router] <Portal> target not found: "#toast-root" — rendering children inline instead.

When there is no document (server rendering), <Portal> returns its children inline rather than null, so server-rendered output still contains the content. There is nothing to portal into on the server; the portal takes effect on the client.

Imported React components work inside a Reactra view block — <Portal> is just another one. A common shape is a page component that pops a toast into a global host:

import { Portal } from "@reactra/router"
import { Toast } from "../components/Toast"
export component SettingsPage {
state saved: boolean = false
action async save() {
await settingsStore.persist()
saved = true
}
view {
<form onSubmit={e => { e.preventDefault(); save() }}>
{/* …fields… */}
<button type="submit">Save</button>
</form>
{saved && (
<Portal to="#toast-root">
<Toast onDismiss={() => { saved = false }}>Settings saved.</Toast>
</Portal>
)}
}
}
Compiled React 19 — this is the file in your repo
import { Portal } from "@reactra/router"
import { Toast } from "../components/Toast"
import { useCallback, useEffect, useState, useTransition } from "react"
export const SettingsPage = () => {
const [saved, setSaved] = useState((false) as boolean)
const [isPending_save, startTransition_save] = useTransition()
const save = useCallback(async () => startTransition_save(async () => { await settingsStore.persist()
setSaved(true) }), [])
save.isPending = isPending_save
const __inline_onDismiss = useCallback(() => { setSaved(false) }, [])
useEffect(() => { const h = globalThis.__REACTRA_TEST__; if (h) h.update("SettingsPage", { saved, save }) })
return (<>
<form onSubmit={e => { e.preventDefault(); save() }}>
{/* …fields… */}
<button type="submit">Save</button>
</form>
{saved && (
<Portal to="#toast-root">
<Toast onDismiss={__inline_onDismiss}>Settings saved.</Toast>
</Portal>
)}
</>)
}
export default SettingsPage

The <Toast> renders into #toast-root (a node you placed near your app root), visually on top of everything regardless of where SettingsPage sits in the layout — while onDismiss still flows back into SettingsPage’s saved state exactly as if it were an inline child.