Host Octane inside React

React compatibility

Already have a React 19 app? octane/react lets you render compiled Octane islands inside it — one component at a time, no rewrite. Events stay native, context is shared, and it renders on the server.

What OctaneCompat is

octane/react exports a single component, <OctaneCompat>, that hosts a compiled Octane subtree inside a real React 19 tree. React owns the wrapper and one host element; a private Octane root owns every descendant — the island.

Because Octane compiles ahead of time, the island is already the framework's own render output. <OctaneCompat> just gives it a place to live inside React, so you can port a screen, a widget, or a single component without touching the React code around it.

Local Octane @try / Suspense / error boundaries inside the island always get first chance to handle a throw or a pending state; only something that escapes the island's own root surfaces to the nearest React Suspense or error boundary.

Set up the toolchain

An Octane island is a compiled .tsrx (or Octane-compiled .tsx) module, while the host app is compiled by React's own toolchain. requireDirective: true tells the two compilers apart: every .tsrx is Octane's by extension, and an Octane-owned .tsx/.ts/.js opts in with a leading /** @jsxImportSource octane */ pragma:

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
// src/islands/Counter.tsrx — compiled by Octane, no marker needed
import { useState } from 'octane';

export function Counter(props: { start: number }) @{
	const [count, setCount] = useState(props.start);
	<button onClick={() => setCount(count + 1)}>{'clicks: ' + count}</button>
}

The ownership rules — what the pragma means and how exclude hands a path to another toolchain — live in Build tools. This page is about what you do once the compilers are wired up.

Render an island

Import the compiled Octane component and the host, then wrap the component in <OctaneCompat>. It takes exactly one compiled Octane element as its child, consumed as a { component, props } transport — React never invokes it:

tsx
// App.tsx — compiled by React's toolchain
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>
	);
}

Both the child site and the island are fully typed: under octane's TypeScript tooling (tsrx-tsc, the editor plugin) the .tsrx export keeps its own octane-typed signature, React JSX accepts it zero-cast, and a wrong, missing, or unknown island prop is a type error at <Counter …/>. (Octane element values remain rejected in ordinary React ReactNode positions — only the component transport crosses the boundary.)

The equivalent component/props form passes the same transport explicitly, with props checked against the island's props parameter; island identity follows the React key on <OctaneCompat> itself, and props may be omitted for an island that takes none:

tsx
<OctaneCompat component={Counter} props={{ start: 3 }} />

Events inside the island are native and delegated at the island host, so React ancestors observe real capture and bubble order, the real target, and stopPropagation() / preventDefault() exactly as the platform reports them — there is no synthetic event layer bridging the two.

Share React context

An island reads your app's real React contexts with ordinary use() / useContext() — pass the React context object straight through:

tsrx
// src/islands/ThemedBadge.tsrx
import { use } from 'octane';
import { ThemeContext } from '../theme.ts';

export function ThemedBadge() @{
	const theme = use(ThemeContext);
	<span className={'badge badge-' + theme}>Octane island</span>
}

The host resolves the nearest committed provider value and keeps the island in sync as the provider updates — no adapter or prop-drilling required. Reading a context that has no provider yields its createContext default, just as it does in React.

Server rendering and hydration

Islands render on the server too. Wherever React runs on the server — Fizz streaming or renderToString — import the host from octane/react/server; the client entry (octane/react) hydrates its output.

tsx
// entry.server.tsx
import { OctaneCompat } from 'octane/react/server';

Each island runs one synchronous server pass; an unresolved island suspension is delegated to React so the shell can stream and fill in. Scoped island CSS is emitted as React 19 style resources (a stable per-hash href with precedence="octane"), so Fizz hoists and deduplicates it across islands. The island's HTML is written as an opaque region the client adopts without re-rendering, so hydration never touches the descendants React did not own.

What isn't supported

One part of React does not cross over:

  • React Server Components — RSC, Flight, and cache(). Islands are client and server-rendered function components; there is no Server Component boundary between React and Octane.

Everything else you rely on in a React app comes along: hooks, Suspense, transitions, context, portals, error boundaries, native events, and server rendering with hydration. See Differences from React for the full list of APIs Octane leaves out.

Editor and type checking

Islands keep their real octane types across the boundary — no declare module '*.tsrx' shims. Two pieces of setup make that work in a React host project:

  1. Type checking: use tsrx-tsc instead of plain tsc so .tsrx islands join the program with their exported types:
json
{ "scripts": { "typecheck": "tsrx-tsc --noEmit -p tsconfig.json" } }
  1. Editor: register the TypeScript plugin in tsconfig.json so imports like ./islands/PriceBadge.tsrx resolve with full types in .tsx files (requires the editor to use TypeScript 5.9/6.x — the plugin API is not available under TS 7 previews):
json
{ "compilerOptions": { "plugins": [{ "name": "@tsrx/typescript-plugin" }] } }

The host tsconfig stays pure React ("jsx": "react-jsx", no jsxImportSource) — each .tsrx island types against octane's JSX on its own, and prop mistakes at <OctaneCompat component={Island} props={…} /> or <Island /> call sites surface as ordinary type errors. See examples/harbor for a complete working setup.

Next

  • Build tools — mixed-toolchain ownership: .tsrx by extension, octane .tsx/.ts/.js via the @jsxImportSource octane pragma.
  • Quick start — install Octane and write your first .tsrx component.
  • Differences from React — the deliberate divergences (everything else matching React is the point).