Use React and Octane together
React compatibility
Host compiled Octane components in a React app with OctaneCompat, or use real React components in an Octane app with ReactCompat. Each renderer owns its subtree, including its hooks, state, and DOM.
Choose a direction
octane/react exports both hosts:
| Your app | Host | What it renders |
|---|---|---|
| React 19 | OctaneCompat | Compiled Octane components inside React |
| Octane | ReactCompat | Real React components inside Octane |
Use matching React and React DOM versions. OctaneCompat supports React 19;
ReactCompat requires 19.2 or newer in the React 19 series.
An island is the subtree owned by the other renderer. Use OctaneCompat to
adopt Octane a component at a time in an existing React app. Use ReactCompat
when an Octane app needs a React component or library that has not been ported.
React stays React; do not alias React or React DOM to Octane.
Every island adds a separate root and its scheduling, event, and lifecycle overhead. Prefer a boundary around a useful subtree over one per tiny widget. The native Octane client runtime does not include React; importing the integration adds it.
Set up the toolchain
Keep each component under its own compiler. For a mixed Vite app, use the Octane and React plugins with explicit source ownership:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { octane } from '@octanejs/vite-plugin';
export default defineConfig({
plugins: [octane({ requireDirective: true }), react()],
});.tsrx files always belong to Octane. With requireDirective: true, mark
Octane-owned .tsx, .ts, and .js application files with a leading
/** @jsxImportSource octane */ pragma, including native hook and context helpers.
Keep React JSX under React's transform with /** @jsxImportSource react */.
requireDirective applies to application modules under the bundler root, so keep
mixed application source inside that root. Installed and linked packages retain
their package ownership rules; a React library that does not declare Octane remains
React-owned. See Build tools for ownership
and exclude rules.
Render Octane in React
Write the Octane component in .tsrx:
// src/islands/Counter.tsrx
import { useState } from 'octane';
export function Counter(props: { start: number }) @{
const [count, setCount] = useState(props.start);
<button onClick={() => setCount(count + 1)}>{'clicks: ' + count}</button>
}Then pass exactly one compiled Octane component element to OctaneCompat in
the React app. React transports the component and its props; it never invokes
the Octane component itself:
/** @jsxImportSource react */
// src/App.tsx
import { OctaneCompat } from 'octane/react';
import { Counter } from './islands/Counter.tsrx';
export function App() {
return (
<main>
<h1>My React app</h1>
<OctaneCompat>
<Counter start={3} />
</OctaneCompat>
</main>
);
}The equivalent component/props form checks props against the component's
signature. Omit props when the component needs none:
<OctaneCompat component={Counter} props={{ start: 3 }} />The .tsrx export keeps its Octane types through tsrx-tsc and the editor
plugin. Missing, incorrect, or unknown props are errors at the child call site;
no cast or ambient .tsrx shim is needed. Octane element values are still not
ordinary React renderables outside this component transport.
Render React in Octane
Keep the React component in a React-owned module. Its hooks and JSX use the real React packages:
/** @jsxImportSource react */
// src/Counter.react.tsx
import { useState } from 'react';
export function Counter({ start }: { start: number }) {
const [count, setCount] = useState(start);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}Use ReactCompat around one React component element in the Octane template:
// src/App.tsrx
import { ReactCompat } from 'octane/react';
import { Counter } from './Counter.react';
export function App() @{
<main>
<h1>My Octane app</h1>
<ReactCompat>
<Counter start={3} />
</ReactCompat>
</main>
}Use a named ReactCompat import; aliases are supported. The Octane compiler
transports the child as an element descriptor so React can render it. Function,
class, memo, lazy, and forwardRef components are accepted. A DOM element,
fragment, array, or multiple children cannot be the island root; put those
structures inside a React component.
The explicit form is also typed:
<ReactCompat component={Counter} props={{ start: 3 }} />Do not combine the child and component/props forms. Any children passed in
props must be React renderables, not Octane template blocks. Prefer JavaScript
default parameters for function defaults: the outer element is authored by
Octane and follows Octane's descriptor and defaultProps normalization.
Share context
React context in an Octane island
An OctaneCompat island reads the React app's real context objects with Octane's
ordinary use() or useContext():
// src/islands/ThemedBadge.tsrx
import { use } from 'octane';
import { ThemeContext } from '../react-theme.ts';
export function ThemedBadge() @{
const theme = use(ThemeContext);
<span className={'badge badge-' + theme}>Octane island</span>
}Here ThemeContext comes from React's createContext. The host resolves the
nearest committed React provider and updates the island when it changes. With no
provider, the island receives the React context's default. No mapping is needed
in this direction.
Octane context in a React island
For ReactCompat, map a native Octane context to a real React context once with
bridgeReactContext:
/** @jsxImportSource octane */
// src/theme.ts
import { createContext } from 'octane';
import { createContext as createReactContext } from 'react';
import { bridgeReactContext } from 'octane/react';
export const Theme = createContext('light');
export const ReactTheme = createReactContext('light');
export const reactContexts = [bridgeReactContext(Theme, ReactTheme)];/** @jsxImportSource react */
// src/ThemedPanel.react.tsx
import { useContext } from 'react';
import { ReactTheme } from './theme';
export function ThemedPanel() {
const theme = useContext(ReactTheme);
return <p>React theme: {theme}</p>;
}// src/ThemedApp.tsrx
import { ReactCompat } from 'octane/react';
import { Theme, reactContexts } from './theme';
import { ThemedPanel } from './ThemedPanel.react';
export function ThemedApp() @{
<Theme value="dark">
<ReactCompat contexts={reactContexts}>
<ThemedPanel />
</ReactCompat>
</Theme>
}The React component receives the nearest Octane provider value, including an
explicit undefined. Updates cross memo boundaries without resetting state.
Mappings are local to each island; providers inside React keep their usual
precedence. Keep the ordered source and target context identities stable for the
boundary's lifetime, or change its key. Duplicate target contexts are rejected.
State, refs, and events
Changing props preserves the island's component state and DOM identity. A child key or type change replaces the component; changing the outer compatibility boundary's key replaces the whole root.
ReactCompat passes ordinary props, callbacks, and React 19 ref props through to
React. Class refs target the React instance. React components keep React's event
behavior, including their usual text-input onChange handlers.
Octane components keep native events, including native onInput for per-keystroke
text changes. In OctaneCompat, those events are delegated at the island host;
React ancestors observe platform capture and bubble order, targets,
stopPropagation(), and preventDefault(). The bridge does not translate events
between the two systems.
Suspense, errors, and visibility
Local boundaries handle their descendants first. An Octane island's escaped
suspension or error reaches the enclosing React Suspense or error boundary. In
the other direction, an escaped React suspension reaches Octane's nearest
@pending/Suspense boundary, and escaped render, layout, or passive-effect errors
reach the nearest Octane catch boundary. Resetting that catch boundary remounts
the React island. Event-handler errors follow the owning renderer's event error
reporting, not its render error boundaries.
ReactCompat starts or updates its React root after the Octane host commits.
Octane root.render() and flushSync() do not synchronously flush React work.
React-local transitions retain React's normal behavior, but an Octane transition
does not wait for the separate React root or roll back committed Octane siblings.
Neither compatibility host provides an atomic transaction across both renderers.
While a React island has escaped as pending, new parent props and context
snapshots are published on reveal. To cancel or replace that pending island,
delete ReactCompat or change its outer key.
When Octane Suspense hides a React island, React layout effects and refs disconnect and its portals hide; passive effects stay connected. Octane Activity hiding also disconnects passive effects. Reveal restores the same React state and nodes. Actual deletion invalidates the island immediately and unmounts React in a microtask, including when the island was hidden or pending. This allows nested React→Octane→React client trees to delete safely during a React commit.
Server rendering and hydration
Both server hosts are exported from octane/react/server. The client entry,
octane/react, hydrates their output. Octane's server compiler retargets
octane/react imports automatically. A custom pipeline, or a React-owned server
entry that does not pass through that compiler, must select the server entry:
// Choose the host used by your server entry.
import { OctaneCompat, ReactCompat } from 'octane/react/server';Octane inside React: OctaneCompat runs a synchronous Octane server attempt
and delegates unresolved suspension to React's server renderer. Fizz can stream
the surrounding fallback and retry the island. Scoped island CSS becomes React
19 style resources, hoisted and deduplicated across islands. The client Octane
root hydrates the island's HTML; React leaves its descendants opaque.
React inside Octane: use Octane's asynchronous or streaming server renderer.
ReactCompat buffers the complete React HTML for each island. An enclosing
Octane streaming boundary can send its fallback while the island is pending;
React's internal progressive reveal scripts are not streamed separately. A
synchronous Octane render can produce a surrounding fallback but cannot await
the React island.
React island HTML is limited to 8 MiB. Pending React work belongs to the Octane server request and is released on abort, cancellation, timeout, or request completion. React server errors reach Octane's server catch path; React error boundaries do not catch server-render errors.
React useId receives a per-island prefix derived from Octane useId. React's
hydrateRoot adopts the existing island DOM, preserving nodes, refs, and
user-edited form state. Server and client must render the same component,
context values, and tree; mismatches inside an island use React's normal
hydration recovery.
Limits
- Both hosts introduce a div:
div[data-octane-compat]ordiv[data-react-compat]. Place them where a div is valid, not directly inside a table row, select, SVG tree, or another restricted content model. Do not let another renderer reconcile or directly write the island's interior. - React Server Components, Flight, and React's server
cache()do not cross these boundaries. - Nested React→
OctaneCompat→ReactCompatserver rendering is unsupported. Client nesting works in both directions. - An
OctaneCompatisland cannot hoisttitle,meta, orlinkoutput during React SSR. Render those head resources from the React tree instead.
See Differences from React for the APIs and
semantics of native Octane components. A component inside ReactCompat still
runs under React's own rules.
Editor and type checking
Use tsrx-tsc for any program containing .tsrx, including a React host app.
It preserves the component's exported types across both compatibility hosts:
{ "scripts": { "typecheck": "tsrx-tsc --noEmit -p tsconfig.json" } }Register the TypeScript plugin for typed .tsrx imports in the editor. Use
TypeScript 5.9/6.x in the editor; the plugin API is unavailable in TS 7 previews:
{ "compilerOptions": { "plugins": [{ "name": "@tsrx/typescript-plugin" }] } }A React host keeps "jsx": "react-jsx" without a global jsxImportSource;
each .tsrx file uses Octane's JSX types on its own. An Octane host uses
"jsxImportSource": "octane" and marks React modules with
/** @jsxImportSource react */. Do not add declare module '*.tsrx' shims:
they erase the types that make the component and ref checks work.
Next
- Playground — choose ReactCompat in Octane (multi-file) for live props, callbacks, refs, local Suspense, and unmount/remount controls, or OctaneCompat in React (multi-file) for the other direction.
- Harbor example — a React app with compiled Octane islands and server rendering.
- Build tools — configure compiler ownership.
- Quick start — create an Octane app and write its first
.tsrxcomponent.