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 appHostWhat it renders
React 19OctaneCompatCompiled Octane components inside React
OctaneReactCompatReal 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:

ts
// 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:

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:

tsx
/** @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:

tsx
<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:

tsx
/** @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:

tsrx
// 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:

tsrx
<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():

tsrx
// 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:

ts
/** @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)];
tsx
/** @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>;
}
tsrx
// 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:

tsx
// 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] or div[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→OctaneCompatReactCompat server rendering is unsupported. Client nesting works in both directions.
  • An OctaneCompat island cannot hoist title, meta, or link output 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:

json
{ "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:

json
{ "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 .tsrx component.