Portal
Portal renders a subtree into a DOM element somewhere else — document.head, a
modal root at the end of <body>, a toast container — while the subtree stays part of
the component that declared it. Its lifecycle, state and context all belong where you
wrote it; only the DOM lands in the target.
It is a hook, so it renders nothing where you declare it:
import { Portal } from "@ramonda/core";
class Toast extends Component<{ message: string }> {
portal = this.use(Portal, (self: Toast) => ({
children: <div className="toast">{self.props.message}</div>,
target: document.getElementById("toast-root")!,
}));
render() {
return null;
}
}
children is whatever an expression slot accepts — one element, a string, or an array —
and target is the element it renders into.
It owns only its own nodes
Two portals into one target coexist, and neither touches what was already there: a shell
tag in <head>, another portal's content. When a portal unmounts it removes only what it
put there. This is the property Head is built on — many Head hooks write into one
<head> without fighting over it.
Reactive children
The callback form re-renders the portalled content when the values it reads change, like any other reactive read:
this.use(Portal, (self) => ({
children: <div className="sheet">{self.rows} rows</div>,
target: document.body,
}));
Because a hook's callback is cached on the signals it read, an unrelated render of the
owner does not re-render the portal — only a change to what children actually depends on
does.
A reactive target, and "inline"
target can change, and the nodes move — the same node, keeping its state, not a
second copy left behind:
this.use(Portal, (self) => ({
children: <div className="sheet">…</div>,
target: self.wide ? document.body : self.localAnchor, // full-screen vs in place
}));
There is no disabled/inline flag: to keep the content in place, point target at an
element in your own render. A target that only becomes available after mount is placed
then, not lost.
list() works here
A portal's children go through the real reconciler, so a list is a list — minted identity, per-item scopes, the whole-list skip. Nothing about it is special because it is portalled:
this.use(Portal, () => ({
children: list(this.rows, (item) => <Row item={item} />),
target: modals,
}));
Reordering moves each row's node, with the component state on it, rather than a neighbour taking its contents. There is no key to write — see lists.
Events follow the DOM, not where you wrote it
There is no synthetic event layer — @onElement attaches a real listener to a real node —
so a portalled subtree's events bubble through the DOM, from the target's ancestors,
not from the component that declared the portal. A handler on an ancestor of the portal's
owner will not see them. Put the handler on the portalled content itself, or on an
ancestor of the target.
Server rendering
A portal into document.head is collected on the server and adopted on the client, so its
tags are in the served HTML and are not duplicated on hydration. That is exactly how
Head puts a page's <title> and <meta> into the document — it is a portal
into the head with the tags kept unique by their identity.
A component inside a portal is hydrated like any other: its host is adopted and its server state restored, not rebuilt from its initial values.
A target outside the app: name it
document.head works because the server's document has one. Every other container — a modal
root in the body — does not exist during a server render: your shell is assembled after the
render returns, so there is no element to point at. Name it instead:
import { portalTarget } from "@ramonda/core";
const modals = portalTarget("modals");
this.use(Portal, () => ({ children: <Dialog />, target: modals }));
The server collects that target's content and hands it back on page.portals, keyed by the
name — from renderPage for a per-request render and from renderStatic for a baked one.
renderDocument emits a container per entry, after the app root, so a modal is
outside the stacking context it is trying to escape.
If you assemble your own shell, mark where they go and fillDocument fills it:
<div id="app"><!--ssr--></div>
<!--portals-->
res.end(fillDocument({ template, html, title, head, portals }));
A shell that collected blocks and has no <!--portals--> is refused, naming the targets. That
one is not a quiet failure on purpose: a page missing its app announces itself, while a dropped
portal renders a page that looks correct and then builds the subtree a second time in the browser.
On the client the name resolves to that container, and the block inside it is adopted rather than built again. With no server render at all — a client-only app — the container is created on demand, so a portal is not a feature that only works on server-rendered pages.
A target inside your own render stays an ordinary element: you have the node, and that is the "inline" case above.
Next
- Head and metadata — the portal you will reach for most.
A portal is one of three ways to affect the page outside your own subtree — see reaching the document for which to reach for when.