Reactive state

Signals

A signal holds a value. Read it while rendering and Octane updates that part of the UI when the value changes.

Use signal$ for a value you change, derived$ for a value you calculate from other signals, and query$ for data you load by a key such as a user ID. All three return a handle you can read with .get(). For data with a lifetime your application manages, use an explicit scope and resource. The application API reference lists the exports used in app code. For data your server owns and many components read and change, such as lists with optimistic edits, see Data.

Every module that reads a signal during rendering needs a runtime import from octane/signals, octane/signals/client, or octane/signals/server. This includes components that receive a handle through props or call a helper that reads it. If the module needs no named import, add import 'octane/signals';. A type-only import or a name ending in $ alone does not enable these reads.

Write state and derive a value

In this compiled browser module, the quantity is writable. The total is read-only and follows the quantity, so changing the quantity is enough to update both.

ts
import { derived$, signal$ } from 'octane/signals';

export const quantity$ = signal$(1);
export const total$ = derived$(() => quantity$.get() * 12);

quantity$.set(3);
console.log(total$.get()); // 36, immediately.

In a module compiled by Octane, module-level declarations share state within one browser document or one server request. Declare signal$, derived$, or query$ inside a component when each mounted instance should have its own value. Use derived$ directly there for a calculation such as total$.

Keep state in a component

useSignal$ is a hook for component-owned writable state. The counter calls .get() during rendering to subscribe and .set() in the click handler to update the value.

tsrx
import { useSignal$ } from 'octane/signals/client';

export function Counter() @{
	const count$ = useSignal$(0);
	<button onClick={() => count$.set((count) => count + 1)}>{String(count$.get())}</button>
}

A lazy initializer such as useSignal$(() => 0) runs once when the hook slot initializes. Keep the $ suffix on variables and props that carry a signal. A value you have already read, such as const count = count$.get(), uses an ordinary name.

Use signals with existing hooks

When you omit a dependency array, useMemo tracks signal reads in its callback.

tsrx
import { useMemo } from 'octane';
import { useSignal$ } from 'octane/signals/client';

export function Counter() @{
	const count$ = useSignal$(0);
	const label = useMemo(() => `Count: ${count$.get()}`);
	<button onClick={() => count$.set((count) => count + 1)}>{label}</button>
}

With an explicit array, read the signal during rendering and put its current value in the array. This component reports the count to its caller after each change.

tsrx
import { useEffect, useMemo } from 'octane';
import { useSignal$ } from 'octane/signals/client';

export function CountLog(props: { recordCount: (count: number) => void }) @{
	const count$ = useSignal$(0);
	const count = count$.get();
	const label = useMemo(() => `Count: ${count}`, [count]);
	useEffect(() => props.recordCount(count), [count, props.recordCount]);

	<button onClick={() => count$.set((n) => n + 1)}>{label}</button>
}

Load data with query$

Use query$(select, load) when a component or module owns the request. The first function selects what to load. The second receives the selection and a context with an AbortSignal. Here the selection is the user ID. The browser examples use the application's same-origin /api endpoints. For server rendering, use a request-aware URL or a server function as in the streaming example.

ts
// user-data.ts — compile this module with Octane.
import { query$, signal$, skip } from 'octane/signals';

export type User = { id: number; name: string; revision: number };
export const selectedId$ = signal$<number | null>(1);
export const user$ = query$<number, User>(
	() => selectedId$.get() ?? skip,
	async (id, { signal }) => {
		const response = await fetch(`/api/users/${id}`, { signal });
		if (!response.ok) throw new Error(`User request failed: ${response.status}`);
		return response.json() as Promise<User>;
	},
);

Setting selectedId$.set(2) selects user 2. Setting it to null returns skip and leaves the query idle. Check for that state before calling .get(). For a selected user, read inside @try so the component can show the loading, ready, and error states.

tsrx
import 'octane/signals';
import { user$ } from './user-data';

export function UserCard() @{
	<section>
		@if (user$.snapshot().status === 'idle') {
			<p>Choose a user.</p>
		} @else {
			@try {
				<p>{user$.get().name as string}</p>
			} @pending {
				<p>Loading user…</p>
			} @catch (error) {
				<p role="alert">{String(error)}</p>
			}
		}
	</section>
}

Call user$.refetch() to refresh the current user while keeping a ready value visible. Call user$.reset() for a fresh attempt that can show pending UI. A component can also select a prop. Here loadUser loads a display name, and the retry button resets both the query and its error boundary.

tsrx
import { query$ } from 'octane/signals';

function RetryButton(props: { retry: () => void }) @{
	<button onClick={() => props.retry()}>Retry</button>
}

export function Profile(props: {
	id: number;
	loadUser: (id: number, context: { signal: AbortSignal }) => Promise<string>;
}) @{
	const user$ = query$(() => props.id, props.loadUser);
	@try {
		<output>{user$.get() as string}</output>
	} @pending {
		<span>Loading</span>
	} @catch (_error, reset) {
		<RetryButton
			retry={() => {
				user$.reset();
				reset();
			}}
		/>
	}
}

Consume streams

For a loader that returns an async iterable, pass { kind: 'stream' } to query$. Here watchNotifications(id, signal) is an application function that returns an AsyncIterable<Notification>.

ts
import { query$, skip } from 'octane/signals';
import { selectedId$ } from './user-data';
import { watchNotifications } from './api';

type Notification = { text: string };
export const notification$ = query$<number, Notification>(
	() => selectedId$.get() ?? skip,
	(id, { signal }) => watchNotifications(id, signal),
	{ kind: 'stream' },
);

The query waits for the first yield. Each yield is its whole next value. A stream that completes after yielding keeps its last value. Completing before the first yield is an error.

Own shared state with a scope

Create a scope when your code decides how long the data lives, for example when a cart outlives the components showing it. Create the scope once when the cart opens. Pass its handles through props or context and dispose it when the cart closes.

ts
import { createScope } from 'octane/signals';

const cart = createScope({ scopeKey: 'cart' });
const quantity$ = cart.signal$('quantity', 1);
const unitPrice$ = cart.signal$('unit-price', 12);
const total$ = cart.derived$('total', () => quantity$.get() * unitPrice$.get());

quantity$.set(3);
console.log(total$.get()); // 36

const stop = total$.subscribe(() => console.log(total$.get()));
quantity$.set(4); // Notifies with 48; subscribe itself does not notify.
stop();

// When the cart's owning session ends:
cart.dispose();

Each key identifies one signal within that scope. An explicit scope.derived$ calculation is synchronous. subscribe observes changes and returns a function that stops the subscription.

Load data in an explicit scope

For the same user lookup, query$ combines selection and loading in one declaration. When you already own a scope, use two functions together:

  • query('user', load) defines a reusable request. Calling the returned function with an ID describes what to fetch.
  • createResource(account, 'selected-user', describe) runs the selection callback, starts the load in the account scope, and returns the readable handle.
ts
import { createResource, createScope, query } from 'octane/signals';

const fetchUser = query('user', async (id: number, { signal }) => {
	const response = await fetch(`/api/users/${id}`, { signal });
	if (!response.ok) throw new Error(`User request failed: ${response.status}`);
	return response.json() as Promise<{ id: number; name: string }>;
});

export function openAccount$(initialId: number) {
	const account = createScope({ scopeKey: 'account' });
	const userId$ = account.signal$('user-id', initialId);
	const user$ = createResource(account, 'selected-user', () => fetchUser(userId$.get()));

	return {
		user$,
		selectUser: (id: number) => userId$.set(id),
		refresh: () => user$.retry(),
		reload: () => user$.retry({ pending: true }),
		close: () => account.dispose(),
	};
}

Call selectUser(2) to load another user. Once a value is ready, refresh() keeps showing it during a retry. Use reload() to show pending UI instead. Resources selecting the same request in the same scope share the load. Call close() when the account session ends.

Derive values across await

Use derived$ for a calculation that depends on other signals, including one that returns a promise. Inside an async calculation, await read(handle$) tracks a dependency even after an earlier await. If the user or locale changes, Octane cancels the old attempt and starts a new one.

ts
import { derived$, signal$ } from 'octane/signals';
import { user$ } from './user-data';

export const locale$ = signal$('en');
export const greeting$ = derived$(async ({ read, signal }) => {
	const user = await read(user$);
	const locale = await read(locale$); // Tracked even after the first await.
	const response = await fetch(`/api/greeting/${user.id}?locale=${encodeURIComponent(locale)}`, {
		signal,
	});
	if (!response.ok) throw new Error(`Greeting request failed: ${response.status}`);
	return response.text();
});

Choose what to show while waiting

Use .get() to read a ready value and let a boundary handle pending work or errors. Use .latest(fallback) to keep showing the last successful result while the next is pending or has failed. Use .snapshot() to distinguish idle, pending, ready, and error states or to check whether a refresh is running.

tsrx
import { derived$ } from 'octane/signals';
import { user$ } from './user-data';

const card$ = derived$(() => ({
	id: user$.get().id,
	name: user$.get().name,
}));
export function UserSummary() @{
	const card = card$.latest(null);
	@if (card) {
		<p>
			{String(card.id)} · {card.name as string}
		</p>
	} @else {
		<p>No user loaded yet.</p>
	}
}

Reading card$.latest(null) during rendering keeps the ID and name from the same user together while a new selection loads.

Save with optimistic feedback

optimistic$ lets the UI show a proposed edit while action$ saves it. Here updateUser is the application's mutation and returns a user with a server-assigned revision. mayHaveReachedServer identifies a lost response whose outcome needs checking.

ts
import { action$, optimistic$ } from 'octane/signals';
import { user$ } from './user-data';
import { mayHaveReachedServer, updateUser } from './api';

export const visibleUser$ = optimistic$(user$, {
	compareAuthority: (incoming, current) => incoming.revision - current.revision,
});

export const saveUser = action$('user.save', async (operation, name: string) => {
	const id = user$.get().id;
	visibleUser$.set((user) => ({ ...user, name }));
	try {
		const saved = await updateUser({ id, name, operationId: operation.id });
		operation.adopt(saved);
	} catch (error) {
		if (mayHaveReachedServer(error)) return operation.uncertain();
		throw error;
	}
});

Call saveUser(name) from an event when the user data is ready. Read visibleUser$.get().name in the UI to show the tentative name. A confirmed receipt replaces the overlay and a definite failure removes it. If the outcome is uncertain, reconcile it with the server and call operation.adopt(value) or operation.reject(). The revision comparison keeps an older receipt from overwriting a newer one.

Keep committed UI during a transition

In a client event handler, use startTransition to keep the current card visible while the next user's data loads. Urgent edits can still update the UI.

ts
import { startTransition } from 'octane';
import { selectedId$ } from './user-data';

export function showNextUser() {
	startTransition(() => selectedId$.set(2));
}

Server rendering and hydration

The standard Octane fullstack host streams the initial HTML and delivers query results to the browser. A component can use the result when it hydrates, even if its code loads later.

Show the page first, then its data

In a fullstack app, put this in src/Page.tsrx. The short delays stand in for database calls. Each query has its own pending boundary, so the title can appear while the messages are still loading.

tsrx
// src/Page.tsrx
module server {
	export async function loadTitle() {
		await new Promise((resolve) => setTimeout(resolve, 200));
		return 'Team chat';
	}

	export async function* loadMessages() {
		await new Promise((resolve) => setTimeout(resolve, 500));
		yield [{ id: 1, text: 'Hello!' }];

		await new Promise((resolve) => setTimeout(resolve, 500));
		yield [
			{ id: 1, text: 'Hello!' },
			{ id: 2, text: 'The next message is here.' },
		];
	}
}

import { loadTitle, loadMessages } from 'server';
import { derived$, query$, signal$ } from 'octane/signals';

const title$ = query$(
	() => 'team-chat',
	() => loadTitle(),
);
const messages$ = query$(
	() => 'team-chat',
	() => loadMessages(),
	{ kind: 'stream' },
);
const draft$ = signal$('');
const draftLength$ = derived$(() => draft$.get().length);

export default function Page() @{
	<main>
		@try {
			<h1>{title$.get() as string}</h1>
		} @pending {
			<h1>Opening chat…</h1>
		} @catch {
			<h1>Could not load the title.</h1>
		}

		@try {
			<ul>
				@for (const message of messages$.get(); key message.id) {
					<li>{message.text as string}</li>
				}
			</ul>
		} @pending {
			<p>Loading messages…</p>
		} @catch {
			<p role="alert">Could not load messages.</p>
		}

		<label>
			Message
			<textarea value={draft$} />
		</label>
		<p>{String(draftLength$.get())} characters</p>
	</main>
}

Register the page in octane.config.ts.

ts
import { defineConfig, RenderRoute } from '@octanejs/vite-plugin';

export default defineConfig({
	router: {
		routes: [new RenderRoute({ path: '/', entry: '/src/Page.tsrx' })],
	},
});

Keep input typed before hydration

value={draft$} binds the textarea to writable state. Octane's early capture script records edits, including clearing the field, before the component loads. The character count updates once the signal engine and view are active.

What reaches the browser

The initial HTML contains both placeholders and the textarea. When a query first becomes ready, the server sends HTML for that boundary. Later message lists travel as data. An active view displays them, and a deferred view keeps its server HTML until it activates. The browser reuses a matching server result, saving a second initial request. Each yield replaces the whole list, so the second yield includes the first message.

Keep server state separate for each request

A custom host using createScope on the server must create a fresh scope per request. To hydrate HTML from a known starting value, pass the same initial seed to the renderer and the browser. Here route$ is a compiled module declaration such as signal$('home').

ts
import { earlySignalBootstrapScript, renderToString } from 'octane/server';
import { createScope, runWithSignalOwner } from 'octane/signals';
import { App, route$ } from './view.tsrx';

export function renderPage() {
	const request = createScope({ scopeKey: 'octane:document' });
	try {
		runWithSignalOwner(request, () => route$.set('thread'));
		const initialDocumentSignals = request.serialize();
		const { html } = renderToString(
			App,
			{},
			{
				signalOwner: request,
				initialDocumentSignals,
				earlySignalBootstrap: 'external',
			},
		);
		return { earlyScript: earlySignalBootstrapScript(), html, initialDocumentSignals };
	} finally {
		request.dispose();
	}
}

Emit earlyScript before the rendered HTML and include initialDocumentSignals once in the response, escaping the JSON for its HTML or script context. On the client, pass the container holding that HTML, the build and document IDs, and the original seed to hydratePage. Initialize the document before importing components that read its signals.

ts
import { hydrateRoot } from 'octane';
import { bootstrapStreamedSignalResults } from 'octane/hydration/streamed-signals';
import type { ScopeSeed } from 'octane/signals';

export async function hydratePage(
	container: HTMLElement,
	buildId: string,
	documentId: string,
	initialDocumentSignals: ScopeSeed,
) {
	const page = bootstrapStreamedSignalResults({
		buildId,
		documentId,
		initialSignals: { version: 1, scopes: [initialDocumentSignals] },
	});
	try {
		const { App } = await import('./view.tsrx');
		const root = hydrateRoot(
			container,
			App,
			{},
			{
				signalOwner: page.signalOwner,
				initialDocumentSignals,
			},
		);
		return { root, page };
	} catch (error) {
		page.dispose();
		throw error;
	}
}

The seed describes the HTML the browser is adopting. A newer live value, including an edit captured before hydration, survives adoption. When the root ends, call root.unmount(). Call page.dispose() when the document ends.

Adopt streamed results before hydration

A custom host can connect a writable signal to an existing textarea without hydrating a component. Render <textarea id="draft" value={draft$} /> on the server using the same compiled draft$ that ./draft-state exports. Install the early capture script before the textarea. In this host the editor starts the document's single bootstrap using the original full signal manifest.

ts
import { bindSignalControl } from 'octane/signals';
import {
	bootstrapStreamedSignalResults,
	type NativeSignalManifest,
} from 'octane/hydration/streamed-signals';

export async function attachEditor(bootstrap: {
	buildId: string;
	documentId: string;
	initialSignals: Extract<NativeSignalManifest, { version: 1 }>;
}) {
	const page = bootstrapStreamedSignalResults(bootstrap);
	try {
		const { draft$ } = await import('./draft-state');
		const textarea = document.querySelector<HTMLTextAreaElement>('#draft');
		if (!textarea) throw new Error('Expected the server-rendered draft textarea');
		const stop = bindSignalControl(textarea, 'value', draft$);
		return {
			signalOwner: page.signalOwner,
			releaseControl: stop, // Before transferring this node to an island.
			dispose() {
				// Only when the document owner ends.
				stop();
				page.dispose();
			},
		};
	} catch (error) {
		page.dispose();
		throw error;
	}
}

Call releaseControl() before handing the node to a component and dispose() when the document ends. For a host that also places streamed HTML, see custom SSR host setup.

Application API reference

Import these APIs from octane/signals, except for the component hook useSignal$, which comes from octane/signals/client. Octane selects the hook's server implementation when it compiles a component for SSR.

ExportWhen to use it
signal$, useSignal$Create writable state and update it with .set(valueOrUpdater). A signal$ can belong to a module or component and stores a function initial value as data. The component hook useSignal$ calls a function initializer once for its hook slot.
derived$Calculate a read-only value from other signals. Use it directly inside a component or at module level. For async work, await read(handle$) tracks a dependency across an earlier await.
query$, skipLoad data for a selected key. Return skip from the selector to leave the query idle. Use .refetch() to refresh while showing a ready value, or .reset() to show pending UI.
createScopeOwn state for a lifetime you manage, such as a cart or an account session. Create keyed signals with scope.signal$ and synchronous calculations with scope.derived$. Call scope.dispose() when the owner closes.
query, createResourceIn an explicit scope, query defines a reusable request. Calling that function with an argument describes a load. createResource selects the request, starts the load in the scope, and returns the handle you read.
optimistic$, action$, isActionUncertainShow an edit while saving it. optimistic$ overlays the edit on a ready source. action$ gives each save its own operation. Use isActionUncertain to recognize a receipt or error that needs reconciliation with the server.
isSignalHandle, isWritableSignalCheck an unknown value before treating it as a readable handle or writing to it with .set().

Read a handle with .get() for its current value. Use .latest(fallback) to show the last successful value and .snapshot() to inspect pending or error states. For ownership, retries, and hydration details, see the signals reference.