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.
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.
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:
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.
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:
Items packed
The number is state. Each click updates it and renders again.
The flow is always the same:
useState(0)gives the first render a count of0.- Clicking the button runs its
onClickfunction. setCountcalculates the next value and asks Octane to render again.- 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:
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:
<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:
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:
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.
'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.
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:
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.
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.
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:
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:
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:
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:
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:
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:
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:
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.
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:
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:
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.
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
| Prop | Default | Decision |
|---|---|---|
when | required | When the preserved server HTML becomes interactive. |
split | true | Whether the compiler creates a deferred child chunk. |
prefetch | none | Whether 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:
| Strategy | Example 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. |
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:
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:
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:
<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:
<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:
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:
<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:
<Hydrate
when={visible()}
fallback={<ReviewsSkeleton />}
onHydrated={() => markReviewsInteractive()}
>
<Reviews />
</Hydrate>| Prop | Type | Purpose |
|---|---|---|
when | HydrationStrategy | (() => HydrationStrategy) | Required activation trigger. |
split | boolean | Generate a deferred child chunk; defaults to true. |
prefetch | strategy or ({ preload, waitFor, signal, element }) | Prepare the split chunk or application resources early. |
fallback | renderable | Loading UI for a later client-only mount or suspension. |
onHydrated | () => void | Called once after the child successfully commits. |
children | renderable | The 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:
| Approach | Who 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:
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.
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.
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.
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.
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.
Product search
The search box stays immediate while the slower result view catches up.
- Pocket cameraCategory: Photography
- Camera shoulder bagCategory: Photography
- Oak standing deskCategory: Workspace
- Desk task lampCategory: Workspace
- Travel headphonesCategory: Audio
- Studio microphoneCategory: Audio
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.
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
Shared element
Transition types
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:
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:
Async form action
Save the prefilled name to see pending and success states, or clear it to see 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.
<!-- index.html -->
<div id="app"></div>// 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.
<div id="app"></div>
<div id="toast-root"></div>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:
Clicks observed by the logical parent: 0
For production notifications, @octanejs/sonner
provides timers, stacking, actions, focus handling, and swipe dismissal:
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.
| Goal | API | What it returns |
|---|---|---|
| Hydratable HTML in one synchronous pass | renderToString | { html, css } |
| HTML that will not hydrate | renderToStaticMarkup | { html, css } |
| Progressive Node.js response | renderToPipeableStream | A pipeable stream controller |
| Progressive Web response | renderToReadableStream | Promise<ReadableStream & { allReady: Promise<void> }> |
| Fully resolved HTML after all data | prerender from octane/static | Promise<{ html, css }> |
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.tsThe 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;usealso 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:
- Add a Reset button that sets the count back to
0. - Accept a
stepprop and add that amount on each click. - 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
.tsrxcontrol-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.