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.
Where it lives
Section titled “Where it lives”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"The default: document.body
Section titled “The default: document.body”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.
Pointing at a specific node
Section titled “Pointing at a specific node”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.
When the target is missing
Section titled “When the target is missing”If the selector matches nothing, <Portal> does not throw and does not
drop your content. It:
- renders the children inline (un-portaled) so they still appear, and
- logs one
console.warnnaming 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.SSR / no-DOM
Section titled “SSR / no-DOM”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.
In a DSL view
Section titled “In a DSL view”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> )} }}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 SettingsPageThe <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.