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.
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.
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.
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.
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.
// 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.
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.
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>.
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.
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.
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.
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.
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.
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.
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.
// 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.
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').
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.
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.
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.
| Export | When 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$, skip | Load 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. |
createScope | Own 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, createResource | In 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$, isActionUncertain | Show 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, isWritableSignal | Check 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.