Learn the essentials

Core APIs

Octane lets you build interactive pages from small TypeScript functions called components. This guide introduces the APIs you will use most, starting from zero.

The mental model

An Octane app is made from components. A component receives information, called props, and returns the elements that should appear on screen. When its state changes, Octane runs the component again and updates only the DOM that needs to change.

InputsProps + state
Your functionComponent
OutputVisible UI

You do not call a component every time something changes. You describe the output, attach event handlers such as onClick, and let Octane keep the page in sync.

Components and props

A component is a TypeScript function used with a capitalized JSX tag such as <WelcomeCard />. Props are its inputs. They let a parent customize a child without the child changing those values.

tsrx
interface WelcomeCardProps {
	name: string;
	unread: number;
}

function WelcomeCard(props: WelcomeCardProps) @{
	<section class="welcome-card">
		<h2>{'Hello, ' + props.name}</h2>
		<p>{'You have ' + props.unread + ' unread messages.'}</p>
	</section>
}

export function App() @{
	<main>
		<WelcomeCard name="Maya" unread={3} />
	</main>
}

The @{ ... } form means “return the final JSX node.” It is shorthand for a normal function body with return, so you can use whichever reads more clearly. A component can also return text, an array, or null when it should render nothing.

Components compose by nesting. The special children prop contains whatever was put between a component's opening and closing tags:

tsrx
interface CardProps {
	children: unknown;
}

function Card(props: CardProps) @{
	<section class="card">{props.children}</section>
}

export function Profile() @{
	<Card>
		<h2>Ada Lovelace</h2>
		<p>Mathematician and programmer</p>
	</Card>
}

Use a fragment (<>...</>) when you need to return several siblings without adding another DOM element. APIs such as memo, lazy, and createPortal solve more specific composition problems and are collected in the API index.

State and events

For signal handles, shared scopes, derived values, or async resources, see Signals. Signals are stable and work with the standard compiler without an extra option.

Props come from a parent. State is a component's own memory. Call useState with an initial value; it returns the value for this render and a function that schedules the next value.

tsrx
import { useState } from 'octane';

export function Counter() @{
	const [count, setCount] = useState(0);

	<section>
		<p>{'Items packed: ' + count}</p>
		<button onClick={() => setCount((current) => current + 1)}>Add one</button>
	</section>
}

Here is that component running for real:

Live example
Try the buttons

Items packed

The number is state. Each click updates it and renders again.

0

The flow is always the same:

  1. useState(0) gives the first render a count of 0.
  2. Clicking the button runs its onClick function.
  3. setCount calculates the next value and asks Octane to render again.
  4. The component sees the new count, and Octane updates the text in the DOM.

The value you read during a render is a snapshot. When the next value depends on the previous one, the updater form—setCount(current => current + 1)—is the safest choice. For objects and arrays, create a new value instead of mutating the existing one.

Text fields use the browser's native input event:

tsrx
import { useState } from 'octane';

export function NameField() @{
	const [name, setName] = useState('');

	<section>
		<label>
			Your name
			<input value={name} onInput={(event) => setName(event.currentTarget.value)} />
		</label>
		<p>{name ? 'Hello, ' + name + '!' : 'Start typing above.'}</p>
	</section>
}

When commit-on-blur is intentional, keep native onChange and mark that local host explicitly. The hint is not rendered to the DOM and does not alter the event:

tsrx
<input
	defaultValue={savedDraft}
	onChange={(event) => save(event.currentTarget.value)}
	suppressNativeChangeWarning
/>

No suppression is needed for select, checkbox/radio inputs, custom elements, or component API props named onChange; they are outside the text-entry diagnostic.

For state with many related updates, useReducer(reducer, initialState) keeps the update rules in one function. It supports the same optional third-item latest-state getter as useState. Most components should begin with useState and move to a reducer only when that makes the code easier to understand.

Keep editable state in sync with useLinkedState

Sometimes local state belongs to something that can change. A profile editor should keep what you type while you are editing one person, but start with the right name when you open another profile:

tsrx
import { useLinkedState } from 'octane';

export function ProfileEditor(props) @{
	const [name, setName] = useLinkedState(props.user.id, () => props.user.name);

	<label>
		Name
		<input value={name} onInput={(event) => setName(event.currentTarget.value)} />
	</label>
}

The first argument is the source. While props.user.id stays the same, name keeps your edits. When the ID changes, the hook returns the new user's name immediately. You do not need an effect, a state update during render, or an extra render to catch up.

The calculation also receives the previous source and local value. That lets you keep an existing selection when it is still available:

tsrx
const [selection, setSelection] = useLinkedState(
	items,
	(nextItems, previous) =>
		nextItems.find((item) => item.id === previous?.value?.id) ?? nextItems[0] ?? null,
);

On the first render, previous is undefined. When the source changes, it is { source, value }. Sources and values are compared with Object.is by default; pass { sourceEqual, valueEqual } as a third argument when a custom comparison is useful. Like useState, the hook also offers an optional third tuple item, getValue, for reading the latest value inside an async or delayed callback.

In TypeScript, the linked value takes the calculation's return type. TypeScript types previous before it reads that return type, so a calculation that reads previous.value should declare its value type with a return annotation, for example (nextItems, previous): Item | null => …, or by annotating previous.

Enforce render snapshots with Strong mode

Strong mode is an opt-in compiler contract: a module promises that rendering reads immutable snapshots and has no side effects, and the compiler reports the violations it can prove. Add "use strong" before a module's imports to try it one file at a time, or set compiler: { strong: true } in octane.config.ts to cover your application.

tsrx
'use strong';

import { useLinkedState } from 'octane';

export function ProfileEditor({ user }) @{
	const [name, setName] = useLinkedState(user.id, () => user.name);
	<input value={name} onInput={(event) => setName(event.currentTarget.value)} />
}

A Strong module cannot update state or read or write ref.current while rendering, or update state synchronously while setting up an effect. The compiler caches eligible calculations and callbacks itself. Effects and imperative handles infer dependencies: an explicit array that differs from the inferred inputs is an error, and an equivalent one is a hint. useMemo and useCallback allow manual dependency control, using explicit lists as written, inferring omitted or undefined lists, and recomputing the memo or refreshing the callback on each render for null. Event handlers still update state, and effects still synchronize external systems.

Strong mode covers enabling it, adopting it in an existing app, replacements for the React idioms it rejects, and every diagnostic.

Octane deep dive: state getters and conditional hooks

Octane optionally provides a third state tuple item: const [value, setValue, getValue] = useState(initial). getValue() reads the latest scheduled value inside a long-lived callback; it does not subscribe or render. Most components only need the first two items.

Hooks are identified by their compiled call site, so they may appear after an early return or inside a condition. Do not put slot-keyed hooks in a plain JavaScript loop: every iteration would share one call site. The context and Promise readers use() and useContext() are the exceptions. Use a keyed @for block or extract a child component instead.

Lists and conditions

Real pages rarely show the same elements every time. In .tsrx, rendered control flow uses directives. @if chooses a branch and a keyed @for keeps each list item's DOM and state attached to the right data.

tsrx
interface Item {
	id: number;
	label: string;
	packed: boolean;
}

export function PackingList(props: { items: Item[] }) @{
	<ul>
		@for (const item of props.items; key item.id) {
			<li>
				{item.label}
				@if (item.packed) {
					<span aria-label="packed">✓</span>
				}
			</li>
		} @empty {
			<li>Your list is empty.</li>
		}
	</ul>
}

Here is the same list with controls that exercise every branch, including the empty state:

Live example
Pack or clear items

Packing list

The keyed list preserves each row while conditions update its status.

1 of 3 packed

The key must be stable for that item—usually an ID from your data. Avoid using the array position when items can be inserted, removed, or reordered. @switch handles several exclusive branches, while @try handles loading and errors.

Sharing data with context

Passing props is usually the clearest way to move information. When many distant components need the same value—such as a theme, locale, or signed-in user—context lets a parent provide it to the whole subtree.

tsrx
import { createContext, use, useState } from 'octane';

const Theme = createContext('light');

function ThemeLabel() @{
	const theme = use(Theme);
	<p>{'The current theme is ' + theme + '.'}</p>
}

export function Settings() @{
	const [theme, setTheme] = useState('light');

	<section>
		<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>Switch theme</button>
		<Theme value={theme}>
			<ThemeLabel />
		</Theme>
	</section>
}

createContext('light') creates the context and its fallback value. Render the context directly, as <Theme value={theme}>, to make a value available below it. The legacy Theme.Provider alias is unsupported and reports OCTANE_CONTEXT_PROVIDER when the compiler can identify the context. use(Theme) reads the closest provider; useContext(Theme) is an equivalent, context-specific spelling.

Context is not automatically global state. Keep changing data close to the components that own it, then lift it to a shared parent or context when several branches truly need the same source of truth.

Refs and effects

Most components only need props and state. Refs and effects are escape hatches for things outside Octane's render flow: focusing an input, connecting to a service, starting a timer, or integrating a DOM library.

Refer to a DOM element with useRef

A ref is a stable object whose current property can hold a DOM element. Changing it does not render the component again.

tsrx
import { useRef } from 'octane';

export function SearchBox() @{
	const inputRef = useRef<HTMLInputElement | null>(null);

	<div>
		<input ref={inputRef} aria-label="Search" />
		<button onClick={() => inputRef.current?.focus()}>Focus search</button>
	</div>
}

Refs are ordinary props in Octane, so your own component can accept ref directly. There is no forwardRef wrapper. A host element can also receive several refs at once, for example ref={[inputRef, measurementRef]}.

Initialize a ref lazily with useLazyRef

For a value that is expensive to create and needs mutable ref semantics, pass a factory to useLazyRef:

tsrx
import { useLazyRef } from 'octane';

export function CacheButton() @{
	const cache = useLazyRef(() => new Map<string, string>());

	<button onClick={() => cache.current.set('greeting', 'hello')}>{'Cache'}</button>
}

The factory initializes the ref when its cell is created; later renders keep the same ref and do not recreate its value. In Strong mode, it replaces React's if (ref.current === null) ref.current = create() idiom, which reads and writes the ref during render. useRef(callback) stores the callback itself, as in React. A useLazyRef factory can also return a function to store as the ref value.

The factory runs during render, before a commit. It may run again for abandoned or retried renders, on remount, and independently during server rendering and hydration. Keep it free of side effects; use an effect for subscriptions and other work requiring cleanup. In Strong mode, the factory follows the rules of a lazy useState initializer: it may read a clock, randomness, or browser state, but it may not schedule work. The ref's current is read or changed from an event or effect, not during render. The experimental Valdi writer's existing adapter ABI does not support this hook.

Synchronize an external system with useEffect

Page metadata does not need an effect. Render <title> directly and Octane hoists it to the document head:

tsrx
export function Article(props: { title: string }) @{
	<>
		<title>{props.title}</title>
		<article>
			<h1>{props.title}</h1>
		</article>
	</>
}

Use an effect when a component must subscribe to a system outside Octane. This version adds a global keyboard shortcut to the search field. The effect connects after the DOM commits and disconnects when the component leaves the page:

tsrx
import { useEffect, useRef } from 'octane';

export function ShortcutSearch() @{
	const inputRef = useRef<HTMLInputElement | null>(null);

	useEffect(() => {
		const focusSearch = (event: KeyboardEvent) => {
			const target = event.target;
			const isTyping =
				target instanceof HTMLElement &&
				(target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName));

			if (
				event.key !== '/' ||
				event.metaKey ||
				event.ctrlKey ||
				event.altKey ||
				event.isComposing ||
				isTyping
			) {
				return;
			}

			event.preventDefault();
			inputRef.current?.focus();
		};

		window.addEventListener('keydown', focusSearch);
		return () => window.removeEventListener('keydown', focusSearch);
	});

	<div>
		<input ref={inputRef} aria-label="Search" aria-keyshortcuts="/" />
		<button onClick={() => inputRef.current?.focus()}>Focus search</button>
	</div>
}

Try both the ref-driven button and the effect-owned shortcut:

Live example
Press /

Shortcut search

The ref points to the input; the effect owns a global keyboard subscription.

Press/outside a form field, or use the button.

The only component value this effect captures is a stable ref, so it connects once and disconnects on unmount. If an effect reads changing props or state, those values are inferred as dependencies. You can still pass an explicit dependency array when you need exact control; [] means mount and unmount only, while null deliberately means every render. Locally declared custom hooks in full-compiled .tsrx/.tsx modules receive the same inference when they transparently forward their callback and final dependency parameter to a supported hook; plain .ts/.js, imported, or transforming wrappers require an explicit list.

Subscribe to external state with useSyncExternalStore

Use useSyncExternalStore when the source of truth lives outside Octane and already offers a subscription. Browser state, a framework-independent store, or a third-party cache can stay authoritative instead of being copied into component state by an effect.

This status component subscribes to a real browser data source:

tsrx
import { useSyncExternalStore } from 'octane';

function subscribe(onStoreChange: () => void) {
	window.addEventListener('online', onStoreChange);
	window.addEventListener('offline', onStoreChange);

	return () => {
		window.removeEventListener('online', onStoreChange);
		window.removeEventListener('offline', onStoreChange);
	};
}

function getSnapshot() {
	return navigator.onLine;
}

function getServerSnapshot() {
	return true;
}

export function NetworkStatus() @{
	const isOnline = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);

	<p role="status">{isOnline ? 'Online' : 'Offline — changes will sync when you reconnect.'}</p>
}

subscribe receives the notification function and returns its cleanup. getSnapshot reads the current value. Keep both functions stable when possible, and keep the same object identity for an object snapshot until its contents actually change.

The optional third function supplies the server snapshot. Octane reads it during server rendering and hydration, then synchronizes to getSnapshot in the browser. This example uses a deterministic online server value; an application store can serialize its real server snapshot instead.

useLayoutEffect runs earlier when you must measure or adjust layout before paint. useInsertionEffect is for styling libraries. useEffectEvent creates a non-reactive callback that an effect can call without reconnecting when its captures change. Its wrapper identity is fresh on each render, but a wrapper registered by a committed effect always calls the latest committed body; do not add Effect Events to dependency arrays.

Measure committed layout with useLayoutSnapshot

Use useLayoutSnapshot when rendered output depends on a measurement of the committed DOM. In Strong mode, it replaces a layout effect that copies a measurement into state, which Strong rejects as a state update in effect setup:

tsrx
import { useLayoutSnapshot, useRef } from 'octane';

export function MeasuredContent() @{
	const content = useRef<HTMLDivElement | null>(null);
	const height = useLayoutSnapshot(() => content.current?.offsetHeight ?? 0, {
		initial: 0,
	});

	<section>
		<div ref={content}>Content to measure</div>
		<p>{'Height: ' + height}</p>
	</section>
}

The callback measures after the DOM and refs are committed, before paint, on each committed render of the component that reaches the hook. The server and first hydration render use initial, or undefined if it is omitted; initial is used only when the hook is first mounted. Snapshots are compared with Object.is by default. Supply equal(previous, next) for a custom comparison, such as comparing the fields of a new object. Without an initial value, previous may be undefined. Only an unequal measurement triggers an update, which may cause another measurement. Make sure the calculation converges; an oscillating measurement reaches the update depth limit.

The callback is for synchronous measurement, not setting state or acquiring resources. It runs in hook order with the component's other layout effects, so call useLayoutSnapshot after any useLayoutEffect that changes the DOM it measures. The hook does not observe layout changes on its own. For changes that can occur without a component commit, such as resizing, have an observer callback update state so the component commits again; that commit measures again. useLayoutSnapshot is available for the DOM renderer; the experimental universal and Valdi renderers do not support it.

Measure resizes with createResizeObserver

Use createResizeObserver from octane for resize callbacks that update state or write DOM that can affect the observed layout. It delivers native measurements in a separate task, after the browser's resize delivery loop has finished. This avoids ResizeObserver loop completed with undelivered notifications caused by publishing those measurements inside the loop, while ordinary state updates keep their microtask batching.

Install the observer in an effect and disconnect it during cleanup:

tsrx
import { createResizeObserver, useEffect, useRef, useState } from 'octane';

export function MeasuredPanel() @{
	const panelRef = useRef<HTMLDivElement | null>(null);
	const [width, setWidth] = useState(0);

	useEffect(() => {
		const panel = panelRef.current;
		if (!panel) return;
		const observer = createResizeObserver((entries) => {
			setWidth(entries[0].contentRect.width);
		});
		observer.observe(panel);
		return () => observer.disconnect();
	});

	<div ref={panelRef} style={{ width: '100%' }}>
		Width:
		{String(width) as string}px
	</div>
}

The return value is a native ResizeObserver. Its callback receives native entries and the observer, with the latest entry retained per target when notifications are coalesced. Call the returned instance's unobserve or disconnect to discard pending entries during cleanup; changing a target's box option also discards its queued entry. The observer can be reused after disconnect. Pass an owning window's constructor as the second argument when needed, for example createResizeObserver(callback, iframe.contentWindow.ResizeObserver).

If rendered output needs its first measurement before paint, render it from useLayoutSnapshot and have the observer callback update state only to request a commit, which measures again. Effects do not run during SSR, and importing the helper does not initialize browser resources. The helper uses the native observer available in that window; it does not polyfill missing ResizeObserver support.

Deferral does not make a cyclic resize calculation converge. Observers inside unmodified dependencies still need an adapter; the global constructor is unchanged.

This recommendation is specific to resize delivery. IntersectionObserver already delivers callbacks in a separate task. MutationObserver delivers in a microtask; keep its native ordering and guard callbacks against reacting to their own writes by narrowing observations and skipping unchanged updates. Consider task deferral only for a reproduced feedback problem, since it does not make a cycle converge.

Loading data and code

The use API reads a Promise during rendering. While it is pending, the closest Suspense boundary shows a fallback. If it rejects, the closest ErrorBoundary shows an error fallback.

tsrx
import { ErrorBoundary, Suspense, use } from 'octane';

interface ProfileData {
	name: string;
	bio: string;
}

function Profile(props: { data: Promise<ProfileData> }) @{
	const profile = use(props.data);

	<article>
		<h2>{profile.name}</h2>
		<p>{profile.bio}</p>
	</article>
}

export function ProfilePage(props: { data: Promise<ProfileData> }) @{
	<ErrorBoundary fallback={<p>We could not load this profile.</p>}>
		<Suspense fallback={<p>Loading profile…</p>}>
			<Profile data={props.data} />
		</Suspense>
	</ErrorBoundary>
}

The parent owns the loading and error experience; Profile can describe the successful case directly. Keep the Promise stable by creating it in your data layer or router rather than creating a new one on every render.

.tsrx also offers the same idea as template control flow:

tsrx
export function ProfilePage(props: { data: Promise<ProfileData> }) @{
	@try {
		<Profile data={props.data} />
	} @pending {
		<p>Loading profile…</p>
	} @catch (error) {
		<p>{'Could not load profile: ' + (error instanceof Error ? error.message : String(error))}</p>
	}
}

This live version creates its Promise in an event handler, then shows the pending and success branches owned by the boundary:

Live example
Start a request

Profile loader

The button creates one stable request; the boundary owns the pending UI.

Load the profile to see the boundary change.

lazy(() => import('./Settings.tsrx')) uses the same Suspense mechanism to load a component's code only when it is needed.

Octane deep dive: parallel data loading

When independent use() calls can start together, the compiler does so automatically and suspends once for the group. Data that truly depends on an earlier result remains sequential. This avoids accidental loading waterfalls without changing the way you write the component.

Deferred hydration

Hydrate keeps useful server-rendered HTML visible while delaying the component work, effects, and events that make that subtree interactive. By default, the compiler also moves the direct children into a separate JavaScript chunk, so the browser does not fetch that code until the boundary is prefetched or activated.

tsrx
import { Hydrate } from 'octane';
import { visible } from 'octane/hydration';

export function ProductPage() @{
	<main>
		<ProductHero />
		<Hydrate when={visible({ rootMargin: '400px' })}>
			<Reviews />
		</Hydrate>
	</main>
}

The server still renders Reviews. During initial hydration its HTML stays visible but dormant. When the boundary approaches the viewport, Octane loads the generated child chunk, adopts the same DOM in place, then enables refs, effects, and events.

Hydrate is experimental and applies only to matching server HTML in the initial document. A boundary first mounted after the app is already running renders normally on the client.

The three performance decisions

PropDefaultDecision
whenrequiredWhen the preserved server HTML becomes interactive.
splittrueWhether the compiler creates a deferred child chunk.
prefetchnoneWhether code or data preparation starts before when fires.

Choose the trigger with when

Import hydration strategies from octane/hydration. Use the trigger that matches how soon the content needs to respond:

StrategyExample use
load()Hydrate with the initial app hydration.
idle({ timeout? })Hydrate when the browser is idle.
visible({ rootMargin?, threshold? })Hydrate near or inside the viewport.
media(query)Hydrate when a media query matches.
interaction({ events? })Hydrate on intent and replay the triggering event.
condition(booleanOrGetter)Hydrate after an application condition becomes truthy.
never()Keep the initial server HTML permanently static.
tsrx
import { Hydrate } from 'octane';
import { condition, idle, interaction, load, media, never, visible } from 'octane/hydration';

export function StrategyExamples(props: { hasConsent: boolean }) @{
	<>
		<Hydrate when={load()}>
			<SiteHeader />
		</Hydrate>
		<Hydrate when={idle()}>
			<RelatedArticles />
		</Hydrate>
		<Hydrate when={visible()}>
			<Reviews />
		</Hydrate>
		<Hydrate when={media('(min-width: 64rem)')}>
			<DesktopChart />
		</Hydrate>
		<Hydrate when={interaction({ events: ['focusin', 'click'] })}>
			<Editor />
		</Hydrate>
		<Hydrate when={condition(props.hasConsent)}>
			<PersonalizedOffers />
		</Hydrate>
		<Hydrate when={never()}>
			<LegalArchive />
		</Hydrate>
	</>
}

The function form chooses a strategy from browser-only information. Octane never calls it on the server, and it must return a strategy synchronously:

tsrx
import { interaction, visible } from 'octane/hydration';

<Hydrate when={() => (window.matchMedia('(pointer: coarse)').matches ? interaction() : visible())}>
	<Recommendations />
</Hydrate>;

Capture interactions before hydrateRoot()

An immediate hydrateRoot() call needs no extra setup: mounting the first Hydrate boundary installs interaction capture as a synchronous fallback. If your client entry awaits route discovery, data, or dynamic imports before it calls hydrateRoot(), install the lightweight capture queue before that work so an interaction during the gap can be replayed:

ts
import { hydrateRoot } from 'octane';
import { initializeHydrationEventCapture } from 'octane/hydration';

initializeHydrationEventCapture();

await prepareClient();
hydrateRoot(document.getElementById('app')!, App);

Calling initializeHydrationEventCapture() more than once is safe; Octane installs the listeners only once per document.

Control the child chunk with split

Splitting is on by default; this boundary defers both component execution and the HeavyReviewsWidget JavaScript:

tsrx
<Hydrate when={visible()}>
	<HeavyReviewsWidget />
</Hydrate>

Use the literal split={false} when that code is already needed elsewhere or a separate chunk would cost more than it saves. Hydration still waits for when:

tsrx
<Hydrate when={idle()} split={false}>
	<SmallBadge />
</Hydrate>

The compiler can split direct JSX children and capture ordinary lexical values. Move hooks and other setup into a child component, or opt out of splitting.

Prepare before activation with prefetch

A strategy-form prefetch downloads the generated child chunk early without making the server HTML interactive. Here the browser may fetch the editor while idle, but the editor still activates only after interaction:

tsrx
import { idle, interaction } from 'octane/hydration';

<Hydrate when={interaction()} prefetch={idle()}>
	<RecommendationEditor />
</Hydrate>;

Strategy prefetching accepts load(), idle(), visible(), media(), or interaction(). Conditions, never(), and the function form are activation-only strategies.

A procedural prefetch can warm data as well as code. Awaited work blocks activation if when fires first; the signal is aborted if the boundary goes away:

tsrx
<Hydrate
	when={visible()}
	prefetch={async ({ preload, signal }) => {
		await preload();
		await warmReviews({ signal });
	}}
>
	<Reviews />
</Hydrate>

Procedural prefetch also works with split={false}; in that mode preload() resolves immediately. Its waitFor(strategy) helper accepts the same five prefetch strategies. Strategy-form prefetch requires splitting because its job is to fetch the generated child chunk.

Fallbacks, completion, and the complete prop surface

fallback is client-only loading UI for a boundary mounted later on the client whose child suspends. It does not replace the preserved server HTML while an initial boundary waits. onHydrated runs once after the child successfully commits on the client, whether the boundary adopts preserved server DOM or mounts client-only:

tsrx
<Hydrate
	when={visible()}
	fallback={<ReviewsSkeleton />}
	onHydrated={() => markReviewsInteractive()}
>
	<Reviews />
</Hydrate>
PropTypePurpose
whenHydrationStrategy | (() => HydrationStrategy)Required activation trigger.
splitbooleanGenerate a deferred child chunk; defaults to true.
prefetchstrategy or ({ preload, waitFor, signal, element })Prepare the split chunk or application resources early.
fallbackrenderableLoading UI for a later client-only mount or suspension.
onHydrated() => voidCalled once after the child successfully commits.
childrenrenderableThe subtree rendered on the server and deferred.

For compiler constraints, nesting behavior, event details, and the full procedural context, read the deferred hydration guide.

Behavior-only roots and external ownership

Sometimes another system owns the HTML: a streaming renderer appends paragraphs, a content platform replaces article sections, or a separate application controls a widget. attachBehaviorRoot adds client behavior to those existing elements without rendering, hydrating, reconciling, or taking ownership of their markup.

Choose the ownership model before deciding how to make the page interactive:

ApproachWho owns the DOM?What runs on the client?
hydrateRoot(container, App)Octane's component tree.The hydrated components, effects, and events.
<Hydrate when={visible()}>Octane after activation.The deferred component tree when it activates.
<Hydrate split={false} when={never()}>The server or a stream.No descendant component tree or handlers.
attachBehaviorRoot(container)The existing owner.Only the explicitly registered behaviors.

The last two approaches work together: keep an externally owned server range static, then attach only the independent behavior it actually needs.

Keep the streamed HTML permanently static

Use the exact two-attribute form below when Octane should render a range on the server but never create its descendant component graph on the client:

tsrx
import { Hydrate } from 'octane';
import { never } from 'octane/hydration';

export function ArticleShell() @{
	<main id="article-shell">
		<Hydrate split={false} when={never()}>
			<article id="streamed-article">
				<a href="#annotation" data-annotation>
					Open annotation
				</a>
			</article>
		</Hydrate>
	</main>
}

The server emits the children without a wrapper; the client preserves those exact nodes without evaluating, hydrating, or reconciling them. Hydrate and never must be direct imports, although locally renamed imports are supported, and the opening tag must have exactly split={false} and when={never()}. Extra attributes, spreads, or indirect values fall back to ordinary deferred-hydration behavior. Component event handlers inside this permanently static range do not run because their client component tree does not exist.

Attach behavior without claiming the DOM

Use the focused octane/behavior entry when this is all the client needs. It does not pull in the component runtime, compiler, or server renderer. The same API and public types are also available from octane when an application already uses that entry.

ts
import { attachBehaviorRoot } from 'octane/behavior';
import { articleStream } from './article-stream.js';

const page = new AbortController();
const shell = document.querySelector<HTMLElement>('#article-shell')!;
const article = shell.querySelector<HTMLElement>('#streamed-article')!;
const owner = Symbol('article stream');

const root = attachBehaviorRoot(shell, { signal: page.signal });

root.registerExternalRange(article, {
	owner,
	ready: articleStream.allReady,
});

let openAnnotation: (event: Event, element: Element) => void;
let observeAnnotation: (element: Element, signal: AbortSignal) => () => void;

const annotations = root.registerBehavior({
	id: 'article-annotations',
	owner,
	target: '[data-annotation]',
	events: ['click'],
	ready: import('./annotations.js').then((module) => {
		openAnnotation = module.openAnnotation;
		observeAnnotation = module.observeAnnotation;
	}),
	adopt(element, { signal }) {
		return observeAnnotation(element, signal);
	},
	handleEvent(event, element) {
		openAnnotation(event, element);
	},
});

await root.ready;

// Remove this behavior independently while retaining its existing markup.
annotations.dispose();

// Dispose every range and behavior, again preserving the container's DOM.
root.dispose();

registerExternalRange identifies who owns a subtree and can wait for that owner's stream to finish. registerBehavior adopts existing selector matches and discovers matching elements added later; its optional ready promise waits for client code or other preparation. A behavior can also target one Element directly. Removing a match, moving it outside the root, or changing its closest owner aborts its per-element signal and runs the returned cleanup exactly once. The external owner remains free to stream, replace, or move its own DOM throughout.

Coordinate ownership, readiness, and native events

External ranges and roots are scoped to their original document. A registered range must belong to its root's container; strictly nested ranges are allowed, and the closest range determines which owner-constrained behaviors apply. Claiming the same element for another owner throws unless { replace: true } explicitly transfers ownership. A replacement whose signal is already aborted never displaces a healthy owner. Disposing a separately managed nested root immediately restores eligible behavior from its surviving ancestor.

Named behaviors can declare already-registered dependencies and mutually exclusive conflicts. Adoption waits for those dependencies, the behavior's own ready promise, and its closest external range. root.ready snapshots the ranges, registrations, and asynchronous adoptions that are active when you read it; each range and behavior also exposes its own ready, signal, and idempotent dispose(). An underlying readiness failure preserves its original error, while cancellation settles promptly even when third-party preparation never finishes.

Delegated event listeners start immediately, before asynchronous behavior is ready. If an interaction arrives during that wait, the eventual handleEvent receives the exact same original native Event, including its genuine isTrusted value. Octane does not redispatch the event, invent trust, repeat a link navigation or form submission, or restore transient user activation after it expires. Code requiring transient activation must execute synchronously during the original browser event.

Attaching a second root to the same live container requires { replace: true }; the previous root is disposed without deleting the DOM. root.dispose() and parent-signal aborts preserve existing markup by default. Pass root.dispose({ preserveDOM: false }) only when clearing the container is explicitly intended.

Responsive updates and actions

Some updates should feel immediate, such as typing. Others may reveal UI that needs to wait for data. A transition marks the second kind so Octane can keep the previous UI visible instead of replacing it with a loading fallback too soon.

Keep the current screen with useTransition

useTransition marks a screen change that can wait. Use it when the next view may need time, but the current view is still useful—opening a dashboard tab, moving between pages, or applying a large filter.

tsrx
export function ProjectTabs() @{
	const [tab, setTab] = useState('overview');
	const [isPending, startTransition] = useTransition();

	<>
		<button onClick={() => startTransition(() => setTab('activity'))}>
			{isPending ? 'Opening…' : 'Open activity'}
		</button>
		<TabPanel tab={tab} />
	</>
}

The click commits Opening… with the current tab first, so the browser can paint it, and TabPanel renders the new tab in a later task. The live example keeps the current report on screen while another one loads. The isPending flag gives you a place to explain what is happening instead of flashing a full-page loading state.

Live example
Try Activity

Project dashboard

Open another report. The current one stays useful while its data loads.

Overview is ready.

Overview

Visitors across your active projects

24.8k

A transition does not make the work faster, and state that controls typing should stay urgent. Read the React useTransition reference for the full API; Octane uses the same component-level pattern.

Let a slow view lag with useDeferredValue

useDeferredValue gives one part of the page permission to be a step behind. A keystroke commits the input with the previous deferred value, so the browser can paint it while a slower result list continues to show the last useful result. The list renders the new value in a later task, and keystrokes that arrive before that task starts replace the value it renders.

tsrx
export function ProductSearch() @{
	const [query, setQuery] = useState('');
	const deferredQuery = useDeferredValue(query);
	const isStale = query !== deferredQuery;

	<>
		<input value={query} onInput={(event) => setQuery(event.currentTarget.value)} />
		<div aria-busy={isStale}>
			<ProductResults query={deferredQuery} />
		</div>
	</>
}

Type “camera” or “desk” below. The input owns the current value; the catalogue receives the deferred copy and keeps its old cards visible while the new search finishes.

Live example
Type camera or desk

Product search

The search box stays immediate while the slower result view catches up.

This is not a debounce: there is no fixed delay, and it does not reduce network requests. It only changes which render may wait. The deferred render is not interruptible either: once it starts, it runs to completion, so a keystroke that arrives during it waits until it commits. As in React, the slow component should skip the urgent render, for example by wrapping it in memo. See the React useDeferredValue reference for initial values, Suspense, and performance details.

Animate visual changes with ViewTransition

ViewTransition handles a different part of the experience: how the old and new screens visually connect. Wrap the element whose appearance or position changes, then make that change inside a transition.

tsrx
export function SavedNotice() @{
	const [visible, setVisible] = useState(true);

	<>
		<button onClick={() => startTransition(() => setVisible((value) => !value))}>
			Toggle notice
		</button>

		@if (visible) {
			<ViewTransition enter="notice-in" exit="notice-out">
				<p>Project saved.</p>
			</ViewTransition>
		}
	</>
}

The lab below turns the three everyday patterns into controls you can try: an item entering or leaving, one item moving between layouts, and tabs sliding in different directions. Browsers without the native API still make the same state changes; they just skip the animation.

Enter and exit

I animate in and out

Shared element

hero

Transition types

Overview

Read the React ViewTransition reference for the component model, or the full Octane View Transitions guide for class maps, shared elements, and Octane's complete API.

Forms and actions

useActionState connects an async action to a form and gives you its result and pending state:

tsrx
import { useActionState } from 'octane';

async function saveName(previousMessage: string, formData: FormData) {
	const name = String(formData.get('name') ?? '').trim();
	if (!name) return 'Enter a name before saving.';

	// Replace this delay with your API or database call.
	await new Promise((resolve) => setTimeout(resolve, 500));
	return 'Saved ' + name;
}

export function ProfileForm() @{
	const [message, submit, isPending] = useActionState(saveName, '');

	<form action={submit}>
		<label>
			Name
			<input name="name" />
		</label>
		<button type="submit" disabled={isPending}>
			{isPending ? 'Saving…' : 'Save'}
		</button>
		<p aria-live="polite">{message}</p>
	</form>
}

The field starts with a real value. Choose Save name to watch the pending and success states, or clear the field and submit it to see validation. The local delay stands in for a request, so the whole example works without a backend:

Live example
Try both paths

Async form action

Save the prefilled name to see pending and success states, or clear it to see validation.

Save the name above, or clear it to try validation.

Submissions queue and run one after another, each receiving the previous result. When several of them finish together, Octane renders only the final result, in a later task than the input that settled them, so that input can paint first.

useFormStatus lets a child of the form read its status. useOptimistic temporarily shows the expected result while an action runs. requestFormReset(form) asks Octane to reset uncontrolled fields after the surrounding action or transition settles.

Roots and rendering

A root connects an Octane component tree to an element in your HTML. Most apps create one root in their client entry file.

html
<!-- index.html -->
<div id="app"></div>
ts
// main.ts
import { createRoot } from 'octane';
import { App } from './App.tsrx';

const container = document.getElementById('app');

if (!container) {
	throw new Error('Missing #app element');
}

const root = createRoot(container);
root.render(App);

Call root.unmount() when another system removes the whole app. If the server already sent this component's HTML, use hydrateRoot(container, App) instead; hydration adopts that DOM and attaches Octane's events rather than building the page again.

Render an overlay with createPortal

A portal places UI in another DOM container without moving it out of the component tree. This is useful for modals, menus, and notification layers that need to escape clipping or stacking contexts.

html
<div id="app"></div>
<div id="toast-root"></div>
tsrx
import { createPortal, useState } from 'octane';

interface SavedToastProps {
	target: HTMLElement;
	onDismiss: () => void;
}

function ToastBody(props: { onDismiss: () => void }) @{
	<aside class="toast" aria-label="Save notification">
		<p role="status">Draft saved.</p>
		<button type="button" onClick={props.onDismiss}>
			Dismiss
		</button>
	</aside>
}

function SavedToast(props: SavedToastProps) {
	return createPortal(ToastBody, props.target, {
		onDismiss: props.onDismiss,
	});
}

export function Editor(props: { toastRoot: HTMLElement }) @{
	const [toastOpen, setToastOpen] = useState(false);
	const [clicksSeen, setClicksSeen] = useState(0);

	<>
		<button type="button" onClick={() => setToastOpen(true)}>
			Save draft
		</button>

		<section onClick={() => setClicksSeen((count) => count + 1)}>
			<p>{'Toast clicks handled by the editor: ' + clicksSeen}</p>

			@if (toastOpen) {
				<SavedToast target={props.toastRoot} onDismiss={() => setToastOpen(false)} />
			}
		</section>
	</>
}

The toast is physically inside #toast-root, but it remains a logical child of the editor's <section>. Clicking Dismiss runs the button handler and then bubbles to the section handler, so its count increases. Context crosses the portal too, unmounting the owner removes the portalled DOM, and stopPropagation() stops the logical ascent. Native listeners installed separately with addEventListener still follow the physical DOM path.

Try the same structure live. The toast appears in the right-hand DOM target; its dismiss event is observed by the logical parent on the left:

Logical source
Event parent
Separate DOM target

Clicks observed by the logical parent: 0

For production notifications, @octanejs/sonner provides timers, stacking, actions, focus handling, and swipe dismissal:

tsrx
import { Toaster, toast } from '@octanejs/sonner';

export function App() @{
	<main>
		<button onClick={() => toast.success('Project saved')}>Save</button>
		<Toaster position="top-right" richColors />
	</main>
}

Sonner positions the <Toaster> host where you mount it rather than calling createPortal; the direct example above is what demonstrates portal event bubbling.

Other rendering tools are intentionally less common:

  • flushSync(callback) forces pending work to finish before the next line. Reserve it for browser or third-party integrations that require the DOM immediately.
  • act(callback) flushes renders and effects in tests. Application code should not call it.

Server and static rendering

Server rendering sends useful HTML before client JavaScript starts. Streaming and single-pass APIs come from octane/server. prerender from octane/static waits for all Suspense data and is useful whenever you want fully resolved HTML, often at build time.

GoalAPIWhat it returns
Hydratable HTML in one synchronous passrenderToString{ html, css }
HTML that will not hydraterenderToStaticMarkup{ html, css }
Progressive Node.js responserenderToPipeableStreamA pipeable stream controller
Progressive Web responserenderToReadableStreamPromise<ReadableStream & { allReady: Promise<void> }>
Fully resolved HTML after all dataprerender from octane/staticPromise<{ html, css }>
ts
import { renderToString } from 'octane/server';
import { App } from './App.tsrx';

const { html, css } = renderToString(App);

For a direct SSG script outside Vite, preload the compiler for your runtime:

node --import octane/compiler/register entry-server.ts

The preload compiles imported .tsrx/.tsx modules and plain TypeScript custom hooks in server mode.

Put html in the page body and css in <head>. The css string holds every scoped <style> sheet and theme the rendered components injected during that request, deduplicated by hash and in emission order, as ready-to-place <style data-octane="hash"> tags that the client's injectStyle recognizes and skips on hydration. The streaming APIs have no css field: they flush each scoped <style> inline with the content that uses it, send the shell first, and reveal Suspense content as it becomes ready.

API index by job

You do not need to memorize this list. Start with components, useState, and event handlers; return here when you have a specific problem to solve.

Remember and share data

  • useStateRemember a value owned by one component.
  • useReducerCentralize several related state updates.
  • useLinkedStateKeep editable state in sync with a changing value.
  • createContextCreate information a subtree can provide.
  • use / useContextRead context; use also reads Promises.
  • useSyncExternalStoreSubscribe to a store managed outside Octane.

Connect to the browser

  • useRefKeep a DOM node or mutable value without rendering.
  • useLazyRefCreate a ref's value once, from a factory.
  • useEffectSynchronize with an external system after paint.
  • useLayoutEffectMeasure or adjust layout before paint.
  • useLayoutSnapshotRender from a committed layout measurement before paint.
  • createResizeObserverPublish resize measurements outside the browser's delivery loop.
  • useInsertionEffectInsert styles before layout effects; mainly for libraries.
  • useEffectEventCall the latest logic from an effect without reacting to it.
  • useIdCreate an accessible ID stable across server and client.
  • useImperativeHandleCustomize the value exposed through a ref.

Load and reveal UI

  • HydrateKeep server HTML dormant and load its behavior on demand.
  • SuspenseShow a fallback while descendants wait.
  • ErrorBoundaryReplace a failed subtree with an error fallback.
  • lazyLoad a component's code on demand.
  • startTransitionMark an update as non-urgent.
  • useTransitionStart a transition and read its pending state.
  • useDeferredValueLet a slow child temporarily use an older value.
  • ActivityHide or prerender a subtree while suppressing its effects.

Handle actions and forms

  • useActionStateTrack an action's result and pending state.
  • useFormStatusRead the nearest parent form's active action.
  • useOptimisticShow an expected result before an action finishes.
  • requestFormResetRequest an uncontrolled-form reset from an action or transition.

Compose and optimize

  • FragmentGroup siblings without an extra DOM element.
  • memoSkip a component render when its props are unchanged.
  • useMemoReuse an expensive calculated value.
  • useCallbackReuse a function identity when a consumer needs it.
  • createPortalRender into another DOM container.
  • ViewTransition / addTransitionTypeCoordinate browser View Transitions.
  • ViewTransitionPseudoElementWork with transition pseudo-elements in advanced integrations.

Roots, resources, and library tools

  • createRoot / hydrateRootStart a client tree or adopt server HTML.
  • attachBehaviorRootAttach behavior to existing DOM without rendering or taking ownership.
  • preload / preinitTell the browser about important resources early.
  • preconnect / prefetchDNSWarm a connection to another origin.
  • createElement / cloneElementCreate or adapt element descriptors; mainly for libraries.
  • isValidElement / isChildrenBlock / ChildrenInspect component children in library code.
  • flushSync / actIntegrate synchronous DOM work or test updates.
  • useDebugValueCompatibility hook; it currently has no visible runtime effect.
  • setSsrSuspenseTimeout / getSsrSuspenseTimeoutConfigure the server's global deadline for unresolved async data.
  • versionRead the installed Octane package version.

Practice

Try these small changes in the counter example:

  1. Add a Reset button that sets the count back to 0.
  2. Accept a step prop and add that amount on each click.
  3. Show “Bag is empty” when the count is zero and “Ready to go” otherwise.
Show a hint

Event handlers can call setCount(0) directly. Read props.step inside the updater, and use an @if block for the two messages.

Next steps

  • Quick start — install Octane and run your first app.
  • TSRX vs TSX/JSX — choose an authoring format and learn the .tsrx control-flow syntax.
  • Strong mode — opt into compiler-checked pure rendering, one module at a time.
  • Integrations and bindings — connect an app framework or use first-party Octane ports of popular libraries.