tunnel-rat
tunnel-rat is a tiny React component relay. Calling tunnel() creates an In component that registers React children in a Zustand store and an Out component that renders the registered children somewhere else. Its unusual strength is crossing React renderer boundaries, such as declaring HTML UI beside a mesh inside @react-three/fiber while rendering that HTML outside the Canvas. It moves element descriptions, not DOM nodes, and it is not a general state, event, or portal system.
A clever, very small solution to a real cross-renderer React problem, especially around @react-three/fiber. Do not add it for normal DOM portals, and treat its dormant 0.x release line, missing React peer declaration, ordering caveat, and effect-based lifecycle as production risks.
Use it if
- You have React elements declared under one renderer, especially @react-three/fiber, that must appear under another renderer's tree
- You want leaf components to contribute toolbar, HUD, inspector, or overlay content without threading render props through every parent
- You need several independently mounted producers to feed a single ordered output and can give every root element a stable key
- You are comfortable with a small Zustand-backed relay whose entire public API is tunnel(), In, and Out
- You only need to render DOM elsewhere in the same React DOM tree: ReactDOM.createPortal is built into react-dom, preserves React context, and avoids another store dependency
- You need to preserve a mounted component while moving it between containers: tunnel-rat registers element values and its children cleanup and re-add cycle can remount or reorder work; react-reverse-portal is designed around stable portal nodes
- You expect mature release guarantees: npm is still at 0.1.2, the latest release was April 2023, and the last repository commit in January 2024 only changed the README
- You cannot tolerate dependency ambiguity: the published manifest imports React but declares no React peer dependency, a gap tracked in open issue 24, while Zustand is the only declared dependency
- You emit several same-type roots without disciplined keys: the README explicitly warns that React can lose order and even mismatch objects, and tells users to treat multiple In components as a list
- You need animation-presence exit semantics: support for Motion's AnimatePresence remains an open request, and removing an In producer removes its children from the store during effect cleanup
Setup reality
Install tunnel-rat and import its default tunnel factory. There are no config files, credentials, browser globals at import time, or native builds. The important setup decision is where to create each tunnel. Put tunnel() in a shared module or at module scope, then import the same object at both producer and consumer sites. Calling it inside a component body creates a new Zustand store on every render and silently disconnects old In and Out instances. The npm manifest lists Zustand 4 as its only dependency even though the source imports React; it does not declare React as a peer, and an open issue records that packaging defect. Your application must already provide a compatible React runtime. Every mounted In uses an isomorphic layout effect to add its children to an array and removes that exact children reference on cleanup. This means server rendering does not collect producer output, and changing a children element's identity causes removal and re-addition after commit. Multiple producers need stable keys on their root elements because the README warns of order loss and mismatched same-type objects. Cross-renderer use also does not make host elements portable: HTML tags must come out under React DOM, while mesh elements must come out inside Canvas. Context does not magically cross renderer roots through the Zustand store, so pass required values as props or establish providers at the output. Version 0.1.2 includes TypeScript declarations, but offers no configuration for ordering, transitions, filtering, multiple channels within one tunnel, or scheduling. Those behaviors require separate tunnel instances or a different abstraction.
Patterns
Create one shared tunnelcreate-shared-tunnel
// ui-tunnel.ts
import tunnel from 'tunnel-rat';
export const ui = tunnel();
Create the tunnel at module scope. Calling tunnel() during a component render creates a new private store and disconnects producers from consumers.
Send content to an outletsend-and-render
import { ui } from './ui-tunnel';
function Producer() {
return <ui.In><button key="save">Save</button></ui.In>;
}
function Toolbar() {
return <nav><ui.Out /></nav>;
}
In renders nothing at its declaration site. Its children appear wherever the matching Out is mounted.
Give every producer a stable root keykey-multiple-producers
function PageActions() {
return (
<>
<ui.In><button key="publish">Publish</button></ui.In>
<ui.In><button key="preview">Preview</button></ui.In>
</>
);
}
The README warns that multiple In components can reorder or mismatch same-type elements without keys. Treat the output as a React list.
Declare HTML inside a Canvas subtreesend-r3f-to-dom
import { Canvas } from '@react-three/fiber';
import { ui } from './ui-tunnel';
export function ScenePage() {
return (
<><aside><ui.Out /></aside><Canvas>
<ui.In><p key="hint">Drag to rotate</p></ui.In>
<mesh><boxGeometry /><meshStandardMaterial /></mesh>
</Canvas></>
);
}
The HTML element is finally reconciled under React DOM at Out. Do not try to render the p element directly as a Canvas host child.
Declare a mesh outside Canvassend-dom-to-r3f
import { Canvas } from '@react-three/fiber';
import tunnel from 'tunnel-rat';
const scene = tunnel();
export function Product() {
return (
<><scene.In><mesh key="item"><boxGeometry /><meshNormalMaterial /></mesh></scene.In>
<Canvas><scene.Out /></Canvas></>
);
}
Mesh host elements must come out inside the fiber Canvas. The tunnel does not convert host element types between renderers.
Remove output when a producer unmountstoggle-contribution
function InspectorContribution({ selected }) {
if (!selected) return null;
return (
<ui.In>
<section key={selected.id}>Editing {selected.name}</section>
</ui.In>
);
}
Unmounting In removes its children during effect cleanup. Exit animation components may not receive the lifecycle they expect.
Pass values explicitly across renderer rootspass-cross-root-data
function SceneLabel({ item }) {
return (
<ui.In>
<button key={item.id} onClick={() => selectItem(item.id)}>
{item.label}
</button>
</ui.In>
);
}
Do not assume a provider inside one renderer root will be available at Out in another root. Capture values in props or provide context at the output.
Use separate tunnels as named channelsseparate-channels
import tunnel from 'tunnel-rat';
export const toolbar = tunnel();
export const statusbar = tunnel();
export function Shell() {
return <><header><toolbar.Out /></header><footer><statusbar.Out /></footer></>;
}
A tunnel has one unfiltered stream. Create another tunnel when content has a different destination or ordering domain.
Send several elements as one contributionsend-fragment
<ui.In>
<React.Fragment key="account-actions">
<button>Profile</button>
<button>Sign out</button>
</React.Fragment>
</ui.In>
Key the fragment itself. The store holds the whole children value as one entry rather than flattening it into separately ordered producers.
Memoize an expensive contributionkeep-children-stable
function MetricsContribution({ rows }) {
const panel = React.useMemo(
() => <MetricsPanel key="metrics" rows={rows} />,
[rows],
);
return <ui.In>{panel}</ui.In>;
}
In removes and re-adds entries when the children reference changes. Memoization can reduce churn, but only use it when the child is genuinely expensive.
Keep the outlet mounted in the shellplace-one-outlet
function AppShell({ children }) {
return (
<div className="shell">
<div className="shell__actions"><ui.Out /></div>
<main>{children}</main>
</div>
);
}
A long-lived Out avoids tearing down every tunneled subtree when route content changes. Producer cleanup still controls which contributions remain.
Render a stable server placeholderavoid-server-output
function ToolbarSlot() {
return (
<div aria-live="polite" suppressHydrationWarning>
<ui.Out />
</div>
);
}
In registers children in an effect, so tunneled content is absent from server HTML and appears after hydration. Do not use it for content required in the initial response.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react-dom | npm | Use createPortal for ordinary DOM-to-DOM placement within one React renderer and context tree |
| react-reverse-portal | npm | Use it when a mounted subtree must appear in different locations without being recreated each time |
| @react-three/drei | npm | Use its Html helper for a single HTML overlay anchored to a 3D object rather than a general cross-tree outlet |