mrkeyoor.com_
Sat 08 Aug 21:01 UTC
npmWeb Frontendupdated 08 Aug 2026

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.

Verdict

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.

API stability3/5The visible API is only a default tunnel factory returning In and Out, so there is little surface to change and README examples remain straightforward. However, npm is still on version 0.1.2, which provides no semantic-versioning promise of compatibility, and behavior such as array ordering, effect cleanup, and Zustand store creation is observable even though it is not expressed as a documented contract.
Docs3/5The README explains the core mental model with concise basic and bidirectional @react-three/fiber examples, and it prominently documents the most dangerous rule: stable keys for multiple In producers. It does not document SSR output, React context behavior, producer remounts, store lifetime, animation exits, package peer requirements, or the implementation's exact ordering mechanics, so production users need to read the short source and open issues.
Maintenance2/5The repository is not archived, but version 0.1.2 was published in April 2023 and the most recent commit, from January 2024, only updated the README. Open issues include a missing React peer dependency, a reported Zustand 4.5 incompatibility, HMR behavior, TypeScript questions, and animation support. A tiny stable package can need few changes, but this queue shows unresolved integration work.
Ecosystem3/5The package recorded 4,142,642 downloads for July 31 through August 6, 2026 and belongs to the pmndrs ecosystem, where cross-renderer composition is a common need. Its interoperability is intentionally narrow: React elements go into a private Zustand store and come out through one component. There is no plugin API, adapter catalog, channel tooling, or documented integration layer beyond React and the @react-three/fiber examples.

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
Skip it if

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

PackageRegistryPick it when
react-domnpmUse createPortal for ordinary DOM-to-DOM placement within one React renderer and context tree
react-reverse-portalnpmUse it when a mounted subtree must appear in different locations without being recreated each time
@react-three/dreinpmUse its Html helper for a single HTML overlay anchored to a 3D object rather than a general cross-tree outlet