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

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.

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.Provider value={theme}>
			<ThemeLabel />
		</Theme.Provider>
	</section>
}

createContext('light') creates the context and its fallback value. A provider makes a value available below it. 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]}.

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.

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.

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 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 search box can receive every keystroke immediately while a slower result list continues to show the last useful result.

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. 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.

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);

Put html in the page body and the deduplicated scoped css in <head>. The streaming APIs can 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.
  • 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.
  • useEffectSynchronize with an external system after paint.
  • useLayoutEffectMeasure or adjust layout before paint.
  • 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.
  • 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.
  • Bindings — use first-party Octane ports of popular libraries.