State with an owner

Signals

Share reactive state, derive values, and coordinate async data with explicit lifetimes.

Signals are a stable Octane API. Import the data engine from octane/signals or the component hook from octane/signals/client. The standard Octane compiler handles native reads automatically: no extra compiler option is needed.

Declare state and derived values

Use signal$(initial) for writable state and derived$(compute) for a read-only calculation. A derived value can be immediate, a Promise, an async iterable, or a Promise of an async iterable. Synchronous calculations stay synchronous:

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.

The compiler gives each declaration a stable source identity. Module-level declarations resolve to document-local state in the browser and request-local state on the server. Roots in one document can share them; separate requests do not share mutable cells. Compiled browser modules can use them without a renderer root. Server reads and writes require a request owner.

Declarations inside a component or custom hook belong to that component instance, and its @try, @if and @for blocks share them. A child that receives a handle as a prop reads its own cell. A function the component creates keeps the component's cells wherever it runs: an event handler a child calls, an effect, or code after await. Once the component unmounts, a call through such a function throws ScopeDisposedError.

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

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

export function Profile(props) @{
	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();
			}}
		/>
	}
}

An optional trailing options object supplies an explicit key: signal$('', { key: 'draft' }) or derived$(() => quantity$.get() * 12, { key: 'total' }). Uncompiled declarations require a nonempty key. The first argument of signal$('draft') is the stored string, not a key. Function initial values are also data; to store a function through the updater-style setter, use handler$.set(() => nextHandler).

Each module that consumes native reads needs a runtime import from octane/signals, octane/signals/client, or octane/signals/server. A type-only import or a $ name alone does not enable native-read compilation. Use import 'octane/signals' in a component that only receives handles as props.

Rendering a handle directly, as in {props.label as string} or title={props.label}, binds it to the DOM when the rendering module has any import from octane/signals. A type-only import type { SignalHandle } from 'octane/signals' is enough and does not enable native reads. $-named expressions such as {props.label$} bind in every module. Elsewhere an ordinary hole renders a plain value, so apps that never pass handles do not ship the binding runtime. For untyped components that render handles from a lazily loaded signals engine, set the opaqueSignalHandles compiler option.

Start with local state

useSignal$ creates a writable signal owned by a component's hook slot. Read it with .get() and update it with .set(value) or .set(updater):

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 native read during rendering subscribes that render scope to the signal. A later write schedules an update. Reads inside events or effects are imperative; they do not create render subscriptions. Writes during rendering, derived calculations, or updater callbacks are rejected.

The handle stays stable for the lifetime of its hook slot. Unmounting disposes it; an escaped handle then throws ScopeDisposedError. A lazy initializer such as useSignal$(() => 0) runs when the slot initializes. Conditional hooks follow Octane's call-site rules. In a loop, use keyed @for or a child component.

Keep $ at the end of signal bindings, properties, and helpers that return signals or expose live reads: count$, props.user$, readCount$(). A sampled value, such as const count = count$.get(), has an ordinary name. These capabilities also tell the compiler to preserve native reads through its caches, including when a component receives a signal or reader through props.

Own shared state with a scope

Create an explicit scope when state belongs to a session, document, or other owner that can outlive individual components. Create it once at that ownership boundary and pass its handles through props or context.

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

Keys are nonempty strings and unique within a scope. Two scopes with the same scopeKey still own separate state. scope.get(handle$) and scope.set(handle$, value) are owner-checked alternatives to the handle methods. Explicit scope.derived$ callbacks must be synchronous; they can read another scope's handles without taking ownership of them.

Writes use Object.is equality. Replace an object or array to publish a change; deep mutation is not tracked. scope.batch(fn) defers notifications until the outer synchronous batch ends, while reads see writes immediately. scope.action(fn) wraps a function in that batching rule, preserving this, arguments, and its result. Neither API rolls back earlier writes on an exception or holds a batch open across await.

Unmounting a consumer removes its subscriptions without disposing shared data. Call scope.dispose() when the data owner ends. Disposal is idempotent, aborts requests, closes streams, releases subscriptions and retained values, and makes surviving handles unusable. Do not recreate shared scopes during rendering.

Load data with queries

Prefer query$(select, load) for new keyed sources. The synchronous selector tracks its signal reads; the loader runs untracked after selection succeeds. Here is a complete data module for a user picker:

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 a new request and cancels obsolete work. Setting it to null returns skip: no loader starts and the snapshot becomes idle. A strict .get() on an idle query throws SignalIdleError, so show a selection prompt before reading it. undefined remains a valid query argument; only skip means no request.

user$.refetch() refreshes the current selection while retaining a usable value with refreshing: true. user$.reset() makes strict reads wait for a new attempt. A loader receives a fresh AbortSignal and an optional previous ready value. Equivalent selected arguments deduplicate within their owner; there is no idle cache of every historical key. Like other declarations, query$ accepts a trailing { key } option.

Read the query inside a pending/error boundary:

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

.get() returns a ready value, throws a thenable while pending, or throws the source's error. It works with @try/@pending/@catch and with Suspense/ErrorBoundary. Retrying data and resetting a UI error boundary are separate operations.

Keep an explicit resource lifetime

For explicitly owned resources, define a loader with query, then select its arguments in createResource(scope, key, describe). The selection callback runs eagerly and tracks synchronous signal reads. The loader runs untracked and receives an AbortSignal.

ts
import { createResource, createScope, query } from 'octane/signals';

const account = createScope({ scopeKey: 'account' });
const userId$ = account.signal$('user-id', 1);
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 }>;
});
const user$ = createResource(account, 'user', () => fetchUser(userId$.get()));

userId$.set(2); // Selects a new request and cancels obsolete work.
user$.retry(); // Keeps a usable current value during a refresh.
user$.retry({ pending: true }); // Makes strict reads wait for the retry.

Equivalent query arguments share an in-flight request within the same data scope. Retrying that request affects all resources selecting it. Separate scopes do not share requests, and entries are removed when no resource selects them. Reusing a query key with an incompatible loader is an error.

The legacy scope.asyncSignal$ method is removed. Importing createScope no longer retains query producers; only query-using callers import that implementation. Module-level declarations can use query$ without creating an explicit scope.

Late results from canceled, retried, or disposed attempts cannot publish, even if a producer ignores its abort signal. Dispose the scope when its owner ends.

Arguments are copied, frozen, and canonicalized before loading. They may contain undefined, null, booleans, finite numbers, strings, dense arrays, and acyclic plain objects with enumerable string data properties. Object key order does not change request identity. Cycles, sparse arrays, accessors, symbols, custom prototypes, and nonfinite numbers are rejected.

Derive values across await

Use derived$ when a computation itself is asynchronous. Its attempt context provides read(handle$) to wait for a dependency and track it, including a dependency discovered after an await:

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

Read greeting$.get() inside the same kind of boundary. Changing either dependency revokes the old attempt's publishing rights, aborts its signal, and starts a new attempt. An obsolete resolution, rejection, yield, or completion cannot publish even if the producer ignores cancellation.

The compiler also tracks supported, statically proven async native reads. Use the explicit read context for opaque helpers and dependencies discovered after await; there is no ambient dependency context across arbitrary async code. Unlike the declaration derived$, scope.derived$ remains synchronous.

Choose what to show while waiting

APIResult
handle$.get()Current ready value; suspends or throws otherwise.
handle$.latest(fallback)Last successful result, or the fallback when none exists. With no fallback, returns undefined until a success.
handle$.snapshot()Immutable idle, ready, pending, or error status record. Only ready records have value; only error records have error.
scope.isPending(() => handle$.get())Whether the expression suspends. Ordinary errors still throw.

Snapshots also expose refreshing, connection, complete, and an optional requestKey. A ready value can still be refreshing or streaming. undefined, null, false, and empty strings are valid values, not pending sentinels.

A synchronous derived computation can also throw a thenable to wait on it. It stays pending until the thenable settles, then runs again. Throwing a thenable that has already settled waits for a signal it reads to change instead.

Use a derived signal to retain one coherent result:

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

const card$ = derived$(() => ({
	id: user$.get().id,
	name: user$.get().name,
}));
const card = card$.latest(null);

latest retains the whole last successful calculation, even through ordinary errors. Keep the identity and commands for that result together: a retained card for user 1 must not acquire user 2's delete command while user 2 loads. Retained results need not have appeared in committed UI. They remain valid only while their contributing data owners are alive; latest does not hide disposal or invalid historical frames.

isPending describes its expression, not background activity. Reading user$.latest('Loading…') can succeed while user$.get() suspends. Use snapshot() to inspect refresh and stream activity.

Consume streams

Use query$(select, load, { kind: 'stream' }) when the loader returns an async iterable or a promise of one. For example, consume an application's notification producer using the selected user:

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' },
);

Here watchNotifications(id, signal) is your producer returning an AsyncIterable<Notification> of complete notification values. Before its first yield, the resource is pending and connection is connecting. Each yield publishes a ready value with an open connection. Normal completion retains the last value, closes the connection, and sets complete: true. Completing before any yield is an error. Cancellation requests both abort and iterator closure.

derived$ can also return an async iterable without a kind option. A Promise of an iterable is unwrapped before publishing. The explicit query plus createResource API supports the same { kind: 'stream' } option on query.

Save with optimistic feedback

optimistic$(source$) overlays tentative values without replacing the source's authority. Write to it inside action$; each operation owns its own overlay. Continuing the user example, assume the application's updateUser returns a User with a monotonically increasing server revision:

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

Render visibleUser$.get() to show the tentative name, then call saveUser(name) from an event or form action and handle its result or error. The operation pins the owner and exact query selection on its first write. A late receipt cannot overwrite a different selected user, even after switching away and back. A definitive rejection removes only that operation's overlay. The comparator accepts only newer authority, so receipts arriving as revision 2 then revision 1 leave revision 2 installed. It orders action receipts, not independently fetched or streamed source updates.

The synchronous prefix is batched. For writes after await in opaque helpers, use operation.set(visibleUser$, update); ambient operation state does not survive arbitrary async code. mayHaveReachedServer is an application-specific transport classifier: losing a response does not prove the mutation failed. operation.uncertain() retains the overlay for explicit reconciliation; isActionUncertain recognizes both that receipt and ActionUncertainError from an OCTANE_RPC_UNCERTAIN transport error. Octane does not retry the mutation or provide durable idempotency/receipt storage. Retain the operation if needed, look up an authorized receipt, then call operation.adopt(value) or operation.reject() once its outcome is known.

Keep committed UI during a transition

Native setters inside an explicit startTransition or useTransition Action stage a private candidate. Existing committed reads, public subscriptions, and affected DOM bindings keep their values until the candidate's presentation is ready. Urgent edits remain live and superseded work loses publishing rights.

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

// In a client event handler, keep the accepted user card while the next loads.
startTransition(() => selectedId$.set(2));

This currently covers local writable, derived, and query producers, including local streams after their first value. Receiver-owned server streams and historical adoption frames are not yet admitted as transition candidates; those paths fail explicitly. scope.action only batches synchronous writes and does not create this renderer transition.

Use signals with existing hooks

Inferred useMemo dependencies include native reads made by the 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>
}

A cache hit preserves its read subscriptions. An explicit array keeps its exact meaning: [count$] tracks the stable handle, not its changing value. To use an explicit array or an effect, sample the signal during rendering:

ts
const count = count$.get();
const label = useMemo(() => `Count: ${count}`, [count]);
useEffect(() => recordCount(count), [count]);

The compiler diagnoses known live reads hidden inside a memo with an explicit array. Omit the array for native tracking, sample the value, or use the existing null form to recompute every render. Compile custom hooks through the Octane toolchain too. Native hook calls support direct named imports, imported aliases ending in $, and non-computed namespace calls.

Server rendering and hydration

Server-side rendering (SSR) sends HTML that the browser can show straight away. Streaming lets the server send the page in parts. The page does not have to wait for its slowest query. Hydration then connects the browser's Octane code to that existing HTML so it can respond to changes.

Signals connect these steps. The server reads a query to render HTML and sends its result to the browser. The browser can use that result even if the component that needs it loads later.

Show the page first, then its data

In an Octane fullstack app, put this in src/Page.tsrx. The short delays stand in for database or service calls so you can see the parts arrive separately. module server keeps those functions on the server:

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 your fullstack app's octane.config.ts:

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

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

The standard fullstack host uses streaming SSR by default and connects the server's query results to the browser. You do not need to create a scope or install a stream receiver for this page.

Here is what happens:

  1. The server reaches both queries. Each pending .get() shows its own @pending content. The initial HTML, called the shell, includes those placeholders and the textarea.
  2. The title arrives and replaces its placeholder. It does not wait for messages.
  3. The first message list arrives and replaces the messages placeholder.
  4. The next list travels as signal data. Once the view is active, it shows the new list. A view whose code is still deferred keeps its server HTML until it activates.

Each yield is the query's whole next value. The example includes the first message in the second list because Octane does not append yielded arrays for you. A stream is ready after its first yield; normal completion keeps its last value. Returning before any yield is an error.

Keep independent sections in separate @try blocks so each can appear when it is ready. Read pending data inside those blocks: reading it in page setup first would suspend the page before it reaches the local placeholders. A real data dependency still has to wait; streaming cannot make its input arrive sooner.

HTML streaming and signal streaming do different jobs. SSR sends the shell and the first ready HTML for each pending boundary. Later query yields send values, not replacement HTML for every yield. An active Octane view, compiled DOM binding, or your own host's presentation code displays those later values.

For real loaders, pass the query callback's signal to work that supports cancellation. A changed selection or disposed owner cancels the old attempt; even a loader that ignores cancellation cannot publish an obsolete result. Keep initial-response streams finite. For ongoing live updates, use a separate connection with an explicit lifetime rather than holding the page response open forever.

Keep input typed before hydration

In the example, value={draft$} connects the textarea to writable state. The browser's native textarea works as soon as it appears. Octane's early capture script records edits, including clearing the field, before the component loads. When the signal engine takes over, those edits win over older initial values.

The capture script only records input and incoming results. The character count starts reacting when the signal engine and its view or binding are running. Use deferred hydration to choose when a component activates, or the custom-host path below to run a small control binding early.

Why this can make a page faster

  • Show useful HTML sooner. The shell and fast sections can appear while slower data is still loading. Buffering the whole page would wait for all of it.
  • Avoid fetching the same initial data twice. A matching streamed query result is reused in the browser, including when it arrives before the query's module loads. Changing the selection or explicitly refreshing can start new work.
  • Avoid unnecessary waiting between independent reads. Separate boundaries let unrelated sections progress. The compiler also starts reads together where it can prove they are independent; arbitrary code is not automatically parallel.
  • Do less work before interaction. Deferred components can load later. Custom hosts can run signals and compiled DOM bindings without loading the general client renderer. Live behavior still needs the code that implements it.
  • Update the parts that read changed state. Native signal reads subscribe their render scopes; direct bindings can update their owned properties without rerunning the whole page.

These are ways to reduce waiting and repeated work, not a fixed speedup for every app. Streaming also sends result data and bookkeeping, and signals need subscriptions. Measure your page's response bytes, JavaScript delivery, and interaction time. The conversation streaming benchmark checks independent arrival, early typing, and result reuse; the performance evidence records measured builds and their limits.

Keep server state separate for each request

Module declarations resolve through the current request owner on the server. For explicit createScope usage, create a scope per request; a module singleton must not hold one user's mutable scope across requests. Keep each scopeKey and signal key consistent between server and client. Distinct owners in one presented graph must have distinct scope keys.

The compiler selects octane/signals/server for local hooks during server compilation. Local useSignal$ state belongs to that render pass and is not serialized into shared-state seeds.

Native completed reads carry a versioned seed manifest into hydration. The client can adopt those historical values without rewinding live writable state. A matching completed resource seed avoids a duplicate client load. A snapshot alone is not a live stream: without a matching streamed-result channel, a ready but incomplete resource starts a quiet browser attempt. With that channel, the browser joins the server's existing attempt and receives its later values. The running server function stays on the server. Root hydration, deferred islands, and streamed segments each keep the values needed to adopt their own HTML.

For explicit embedding, scope.serialize() returns a ScopeSeed, and scope.beginAdoption(seed) returns an immutable historical frame. Use frame.run(read) for synchronous reads, frame.retain() to acquire an independent lease, and frame.release() to end a lease. Releasing a frame does not rewrite live state. Serialized values follow the same data restrictions as query arguments.

Pending producers and error objects are not serialized. Render pending/error UI inside a boundary, or use a serializable latest projection. Completed server output that directly displays a pending/error snapshot or an isPending result is not supported by ready-state transport and receives a diagnostic.

Share the initial document history

A custom host can emit one initial document ScopeSeed, then supply that seed as initialDocumentSignals to both the server renderer and hydrateRoot. The renderer snapshots it once per request. The client snapshots it once per root, and deferred <Hydrate> boundaries and streamed Suspense arms borrow that immutable history when they activate.

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

const request = createScope({ scopeKey: 'octane:document' });
runWithSignalOwner(request, () => route$.set('thread'));
const initialDocumentSignals = request.serialize();
const rendered = renderToString(
	App,
	{},
	{
		signalOwner: request,
		initialDocumentSignals,
	},
);
// Emit initialDocumentSignals once in the host's early document bootstrap,
// before rendered.html. Escape bootstrap JSON for its HTML/script context.

route$ is a compiled module declaration such as signal$('home'). Each matching native manifest references the seed's exact signal key and read channel. Matching includes the complete encoded entry, including query identity and readiness metadata; equal displayed text alone is insufficient. A read that differs from the initial seed keeps its own entry. A manifest contains only the historical reads its own HTML needs, so an unrelated initial entry does not silently become available during that boundary's adoption.

For a host using the streamed-result bridge, initialize the live document once from that original seed before importing consumers:

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

// The host installed the parser-time signal/selection bootstrap earlier.
const page = bootstrapStreamedSignalResults({
	buildId,
	documentId,
	initialSignals: { version: 1, scopes: [initialDocumentSignals] },
});
const { App } = await import('./view.tsrx');
const root = hydrateRoot(
	container,
	App,
	{},
	{
		signalOwner: page.signalOwner,
		initialDocumentSignals,
	},
);

initialDocumentSignals must retain the original response's seed, with the same build, document and owner authority on both sides. Do not replace it with a snapshot of later live state. The seed's scope key must match signalOwner's document owner; omitted owners use octane:document. Hydration history does not initialize the live owner. An existing write before activation remains live: the boundary first adopts its server value through accepted refs and layout work, then reconciles with current state after commit. Later streamed results continue through the existing owner and do not reinstall this seed.

Pass initialDocumentSignals and the same signalOwner to bootstrapIndependentHydration or registerIndependentHydrationIsland for parent-free boundaries. Registration captures the seed before delayed module loading, and activation forwards it to its own hydration root. Root unmount releases the root's retained history without retiring the borrowed data owner.

Without this option, native transport remains the existing version 1 manifest. When at least one entry matches, the renderer emits a version 2 NativeSignalManifest: scopes carries distinct entries and initialDocument carries { scopeKey, entries: [{ key, read }] } references. Missing seeds, unresolved references, duplicate references and overlapping reference/entry history are rejected. The host's early bootstrap always carries the full version 1 seed; a compact rendered manifest is not a document initialization seed.

createStreamedRegionPlacementFrame packages a standalone historical frame. If its render used compact history, also provide initialDocumentSignals in its placement options so the helper can materialize the exact full history required by that protocol. Standalone placement frames still transport their own historical values. Their HTML keeps its compact native sidecars, so pass the original seed again when hydrating the placed content.

Adopt streamed results before hydration

This section is for custom hosts that manage their own HTML and browser entry. The standard fullstack host does this setup for you.

The streamed-result channel is separate from ready-state seeds. A host can deliver accepted query results before the query module or a component root loads. The receiver validates document, instance, selection, and attempt identity; a matching channel settles the later query without a duplicate browser request. Changing selection or retiring the owner invalidates the old channel.

For custom hosts that already own HTML delivery, use bootstrapStreamedSignalResults before importing modules that read document signals. This example attaches a writable draft$ to an existing textarea; ./draft-state exports signal$('') compiled with the same build as the server:

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: NativeSignalManifest;
}) {
	const page = bootstrapStreamedSignalResults(bootstrap);
	try {
		const { draft$ } = await import('./draft-state');
		const textarea = document.querySelector<HTMLTextAreaElement>('#draft')!;
		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;
	}
}

The host supplies the matching build/document identity and the initial response's native manifest. Install it once before live reads, not from a later cached HTML frame; early edits take precedence and late/duplicate installation throws. The envelope-owning server host emits earlySignalBootstrapScript({ nonce }) from octane/server before bound controls or results, and uses earlySignalBootstrap: 'external' on fragment renderers. The inline script captures early input/results; live computations still require the signal engine.

bindSignalControl adopts captured edits and performs native writeback for writable value or checked handles. It updates the existing control without hydration, reconciliation, or a renderer root. Read-only handles project values without writeback; they do not set the HTML readOnly property. Call releaseControl() before replacing the node or handing it to an island; keep the shared owner alive until the document ends, then call dispose().

Choose bootstrapStreamedSignalHydration instead if the host uses receiver.registerRegion to let Octane place streamed HTML. Use one bootstrap per document and the same bundled signal engine for early and later consumers. Later roots join the returned signalOwner. The result-only bootstrap does not place HTML. For compiled presentation bindings, lifecycle handling, and custom owners, see the integration reference. Deploy matching server/client compiler output and invalidate cached HTML/assets together.

API reference and supported scope

Entry pointExports
octane/signalssignal$, derived$, query$, skip, optimistic$, action$, isActionUncertain, createScope, createResource, query, bindSignalControl, public types and error classes. No renderer import.
octane/signals/clientuseSignal$ for component-owned writable state.
octane/signals/serverServer implementation of useSignal$, selected by server compilation.
octane/hydration/streamed-signalsbootstrapStreamedSignalResults for results only, or bootstrapStreamedSignalHydration for results and HTML placement.

scope.inspect() reports node, dependency, subscription, request, and adoption metadata without evaluating dormant computations or exposing values and callbacks. Enable a bounded metadata trace with createScope({ scopeKey, debug: { traceLimit: 256 } }). Profiling builds can inspect native reads through DevTools.

The error classes include ScopeDisposedError, SignalCycleError, SignalFrameError, SignalIdleError, SignalSerializationError, SignalStreamError, SignalWriteError, and ActionUncertainError.

Native rendering supports the DOM client and server. Local useDerived$ and local async hooks are not available; use explicitly owned scope.derived$ and createResource, or the owner-optional derived$ and query$ declarations. Facade derived$ supports asynchronous callbacks. Deep stores, cross-root atomic reveal, and native reads in non-DOM renderers are outside the API.

The separate @octanejs/alien-signals binding keeps its existing API. For the full ownership and transport contract, see the signals reference.