Server data

Data

octane/data loads, caches and saves the data your server owns. Declare each read and write once, and every component, page and tab that uses it shows the same values, the same pending edits and the same rollbacks.

Most apps spend a surprising amount of code on server data. A list has to be loaded, shared with the other components that show it, kept after a component unmounts, reloaded when it is stale, and updated the moment the user edits it — then put back if the server refuses the edit. A server render has to hand its data to the browser so hydration does not load it again. And if the app is open in two tabs, each one should see the other's changes.

octane/data does that work for you. You describe what your data is, and it decides when to load, cache, save, share and roll back. It has no renderer of its own, so the same store drives Octane components, React components, and plain code such as tests.

Signals are the tool for state your app owns, such as a draft or a selected tab, and for one-off async values. Reach for octane/data when the server owns the data and several parts of the app read and change it.

How the pieces fit

octane/data is built from five small parts. Each one has a single job, and you will meet all of them in the first example.

  • A contract describes one operation: a read (load something), a write (change something), a view (a sorted list), or a feed (a stream of server events). A contract is only a description. Importing one does no network work, so the browser, the server and your tests can all import the same file.
  • A store runs contracts in the browser. It caches each read's value, shares one request between every component that asks for the same thing, shows a write's edit straight away, and puts it back if the server refuses.
  • A transport carries the store's requests to the server. It is one function, so you can send them over fetch, through a module server function, or anywhere else.
  • A handler runs on the server. It pairs each contract with the function that serves it, and validates every input before your code sees it.
  • A binding connects a store to your components: octane/data/octane for Octane and octane/data/react for React. Both have the same hooks.
  • Contractsread, write, view, feed
describe
  • ComponentsuseRead, useForm
binding
  • Storecache, edits, rollback
request
  • TransportserverTransport or httpTransport
wire
  • HandlercreateDataHandler validates and runs
One request: a component asks the store, the store asks the transport, and the server's handler answers.

Each part lives in its own entry point, so a page only ships what it uses:

Entry pointWhat it holdsWhere it runs
octane/dataContracts, the store, transports, feeds, persistence, and cross-tab sharingBrowser and server
octane/data/servercreateDataHandler, which serves contractsServer only
octane/data/octaneThe Octane binding: DataProvider, useRead, and the other hooksOctane components
octane/data/reactThe React binding, with the same names and behaviorReact components

The Octane binding has a server version too. You never import it yourself: when Octane compiles a component for a server render, it points octane/data/octane at the server version for you.

Your first read

Let's build a small reading list from the beginning: a list of books, loaded from the server and shown in a component. It takes four short files.

1. Describe the data

Contracts check data with a schema: a validator that says what shape a value must have. octane/data accepts any validator that implements Standard Schema, the shared interface of Zod, Valibot, ArkType and others. These examples use Zod.

ts
// contracts.ts — imported by the server and by the browser.
import { read } from 'octane/data';
import { z } from 'zod';

export const Book = z.object({
	id: z.string(),
	title: z.string().trim().min(1, 'Enter a title'),
	read: z.boolean(),
});
export type Book = z.infer<typeof Book>;

export const books = read({
	name: 'books.list',
	output: z.array(Book),
});

A read has a unique name, which is how the server finds its handler, and an output schema for the value it returns. It can also take an input, which you will see shortly.

2. Serve it

On the server, createDataHandler pairs each contract with the function that answers it. The handler validates every input before your function runs and every output before it leaves, so your code never sees data that does not match its schema.

ts
// server.ts — server code only.
import { createDataHandler } from 'octane/data/server';
import { books, type Book } from './contracts.ts';

const shelf: Book[] = [
	{ id: '1', title: 'Kindred', read: false },
	{ id: '2', title: 'Piranesi', read: true },
];

export const handler = createDataHandler({}, (handle) => [handle(books, () => shelf)]);

The first argument holds options, such as how to recognize the signed-in user. The second lists your handlers. Options come first so the handlers below can be typed from them, as Serve your contracts shows.

The browser needs a way to reach handler. In an Octane app, the simplest way is a module server block: a function that runs on the server, which the browser calls as if it were local. The compiler replaces it in the browser with a small stub that sends the call to the server, with your app's origin checks and middleware.

tsrx
// data.tsrx — the one door between the store and the handler.
module server {
	import type { ServerCallContext } from 'octane/server';
	import { handler } from './server.ts';

	export async function data(request: string, call: ServerCallContext) {
		return handler.serve(request, call);
	}
}

import { data } from 'server';

export { data };

handler.serve answers one store request. If you would rather expose a plain HTTP endpoint, see Serve over HTTP.

3. Create a store

A store needs a transport. serverTransport(data) sends each request through the data function above.

ts
// store.ts
import { createDataStore, serverTransport } from 'octane/data';
import { data } from './data.tsrx';

export const store = createDataStore({ transport: serverTransport(data) });

Create one store per signed-in account and keep it for as long as the account stays signed in. The store is where cached values live, so a new store starts empty.

4. Show it in a component

Put the store in a DataProvider near the top of your app. Any component below it can then get the store with useDataStore(), ask it for a read with store.read(books), and show that read with useRead.

tsrx
// App.tsrx
import { DataProvider, useDataStore, useRead } from 'octane/data/octane';
import { books } from './contracts.ts';
import { store } from './store.ts';

function BookList() @{
	const list = useRead(useDataStore().read(books));
	<ul>
		@for (const book of list; key book.id) {
			<li>{book.title as string}</li>
		} @empty {
			<li>The shelf is empty.</li>
		}
	</ul>
}

export function App() @{
	<DataProvider store={store}>
		@try {
			<BookList />
		} @pending {
			<p>Loading the shelf…</p>
		} @catch (error) {
			<p role="alert">{`Could not load the shelf: ${(error as Error).message}` as string}</p>
		}
	</DataProvider>
}

There is one way to read, wherever you are. store.read(books) returns a handle for that read: the same thing code outside a component uses to get a snapshot, subscribe, or refresh. Asking twice for the same read and input gives you the same handle, so calling it during every render is fine. A component passes the handle to useRead, which keeps the component in step with it for as long as it is mounted.

useRead returns the value itself, not a loading state. While the first value is on its way, it suspends: the nearest @pending arm (or <Suspense> boundary) shows instead. If the first load fails, it throws to the nearest @catch (or error boundary). Once a value has arrived, later reloads keep it on screen, so the list never flickers back to "Loading".

The same component in React looks like this. Only the binding import and the JSX change:

tsx
/** @jsxImportSource react */
// App.tsx
import { Suspense } from 'react';
import { DataProvider, useDataStore, useRead } from 'octane/data/react';
import { books } from './contracts.ts';
import { store } from './store.ts';

function BookList() {
	const list = useRead(useDataStore().read(books));
	if (list.length === 0) return <p>The shelf is empty.</p>;
	return (
		<ul>
			{list.map((book) => (
				<li key={book.id}>{book.title}</li>
			))}
		</ul>
	);
}

export function App() {
	return (
		<DataProvider store={store}>
			<Suspense fallback={<p>Loading the shelf…</p>}>
				<BookList />
			</Suspense>
		</DataProvider>
	);
}

That is the whole loop: a contract, a handler, a store, and a hook. Everything else on this page builds on it.

Reading data

Reads with an input

Most reads take an argument, such as an ID or a search term. Give the contract an input schema and pass the input to store.read as its second argument:

ts
import { read } from 'octane/data';
import { z } from 'zod';

export const book = read({
	name: 'books.byId',
	input: z.object({ id: z.string() }),
	output: z.object({ id: z.string(), title: z.string(), read: z.boolean() }),
});
tsrx
import { useDataStore, useRead } from 'octane/data/octane';
import { book } from './contracts.ts';

export function BookTitle(props: { id: string }) @{
	const value = useRead(useDataStore().read(book, { id: props.id }));
	<h1>{value.title as string}</h1>
}

The store identifies each cached value by the read's name and its input, so two components that ask for book with { id: '1' } share one value and one request, while { id: '2' } is a separate entry. Inputs are compared by value, not by object identity, so a fresh object literal on every render is fine.

Read without suspending

Sometimes you would rather show a value and its status yourself than suspend. Pass { suspend: false }, and useRead returns the handle's snapshot instead of the value. It still keeps the component in step with the read:

tsrx
import { useDataStore, useRead } from 'octane/data/octane';
import { books } from './contracts.ts';

export function ShelfCount() @{
	const snapshot = useRead(useDataStore().read(books), { suspend: false });
	<p>
		@if (snapshot.status === 'pending') {
			<span>Counting…</span>
		} @else if (snapshot.status === 'error') {
			<span>Count unavailable</span>
		} @else {
			<span>{`${snapshot.value!.length} books${snapshot.refreshing ? ' (updating)' : ''}`}</span>
		}
	</p>
}

A snapshot has these fields:

FieldMeaning
status'pending' until the first value arrives, then 'ready', or 'error' if the first load failed.
valueThe newest value from the server, with any pending edits applied on top.
errorWhy the load failed, when status is 'error'.
refreshingA reload is in flight while the current value stays on screen.
staleThe value is older than the read allows, or a write marked it stale; it reloads when observed.
moreFor a view, whether rows follow the ones shown.

How long values last

Three options on a contract say how long a value is good for. All three are in milliseconds.

  • fresh (default 0): how long a value is served without asking the server again. With the default, a component that mounts shows the cached value straight away and reloads it in the background.
  • keep (default five minutes): how long a value is kept once no component is showing it. After that it is dropped, and the next reader loads it again.
  • expire (default never): the age after which a value is never shown, even while it reloads.
ts
import { read } from 'octane/data';
import { z } from 'zod';

export const stats = read({
	name: 'books.stats',
	output: z.object({ total: z.number().int(), read: z.number().int() }),
	fresh: 30_000, // Show it for 30 seconds without reloading.
	keep: 600_000, // Keep it for 10 minutes after the last reader leaves.
	tags: ['shelf'], // Writes that change the shelf mark it stale.
});

tags group reads so a write can mark them stale together. You will see how in Writing data.

Read outside a component

Hooks are a thin layer over the store, and you can use the store directly in event handlers, route loaders, tests or any plain code. store.read returns a read handle. Asking for one does no network work; the handle loads only when something subscribes, prefetches or refreshes.

ts
import { createDataStore, serverTransport } from 'octane/data';
import { books } from './contracts.ts';
import { data } from './data.tsrx';

const store = createDataStore({ transport: serverTransport(data) });

// Start a load early, such as when the pointer hovers a link.
await store.prefetch(books, undefined);

const handle = store.read(books, undefined);
const stop = handle.subscribe(() => {
	const snapshot = handle.getSnapshot();
	if (snapshot.status === 'ready') console.log(`${snapshot.value!.length} books`);
});

await handle.refresh(); // Reload now, keeping the current value on screen.
stop(); // The last subscriber leaving starts the `keep` timer.
  • getSnapshot() returns the current snapshot. It is the same object until something changes, so it can back useSyncExternalStore directly.
  • subscribe(notify) observes the read. The first subscriber starts a load if the value is missing or stale. It returns a function that stops observing.
  • refresh() reloads now and resolves once the reload settles.

The store has a few more methods for plain code:

  • store.invalidate(tags) marks every read with one of these tags (or holding one of these entities) stale. Reads on screen reload.
  • store.publish(read, input, value) hands the store a value you got elsewhere, as if a load had returned it.
  • store.forget(read, input) erases one cached value, in memory and in persistent storage.

Writing data

A write contract describes a change. You call it with store.write(contract, input), the same in a component as anywhere else, and get back a promise for the server's reply. In a component, get the store with useDataStore().

ts
import { write } from 'octane/data';
import { z } from 'zod';

export const renameShelf = write({
	name: 'shelf.rename',
	input: z.object({ name: z.string().min(1) }),
	output: z.object({ name: z.string() }),
	invalidates: ['shelf'],
});

After the server confirms the write, every read tagged shelf is marked stale, and the ones on screen reload. That is the simplest way to keep reads up to date: name what a write affects, and the store takes care of the rest.

Entities: one record, many reads

Reloading is correct, but it is slow and it flickers. Usually the app already knows what the write will do: toggling a book's read flag changes that book, wherever it appears. An entity tells the store which values are records with an identity, so one edit can update every copy.

ts
import { entity, read, write } from 'octane/data';
import { z } from 'zod';

const bookShape = z.object({ id: z.string(), title: z.string(), read: z.boolean() });

/** Every read that holds a book shares its fields. */
export const Book = entity('book', bookShape);

export const books = read({
	name: 'books.list',
	output: z.array(bookShape),
	// Tell the store where the books are in this read's value.
	entities: (list) => Book.from(list),
});

export const setRead = write({
	name: 'books.setRead',
	input: bookShape.pick({ id: true, read: true }),
	output: bookShape,
	patches: Book, // The input is a partial book, keyed by its `id`.
	record: (book) => book, // The reply is the saved book.
});

entity(name, schema, key?) names a record type. Its ID lives in the id property unless you pass another key. A read that holds records says where they are with entities, using Book.from(...) on one record or an array.

A write then says what it does to that entity:

  • patches: Book merges the write's input into the record with that ID.
  • creates: Book adds a new record. Its draft(input, id) option builds the record to show before the server replies; by default the draft is the input plus the new ID.
  • removes: Book removes the record with the input's ID from every read.

record tells the store where the saved record is in the reply, so it can take the server's version without reloading anything.

Optimistic edits and rollback

A write with an effect is optimistic: the store shows its edit in the same moment the user acts, before the request even leaves. If the server confirms, the store keeps the server's reply. If it refuses, the edit is removed from every read that showed it.

  1. 1. store.write(setRead, { id, read: true })
  2. 2. Apply the edit to every read holding the book; re-render now
  3. 3. Send the write, once, with an operation ID
  4. 4. Validate input, run the handler
  5. 5. Confirmed: reply with the saved book and refreshed reads
  6. 6. Show the server’s values; the promise resolves
  7. 7. Or refused: DataError with a code
  8. 8. Remove the edit everywhere; the promise rejects
An optimistic write: the edit shows at once, then the reply either confirms it or rolls it back.

Here is a checkbox that marks a book read. Notice there is no local state for "saving": the list shows the edit because the store does.

tsrx
import { useDataStore, useRead } from 'octane/data/octane';
import { books, setRead } from './contracts.ts';

export function BookList() @{
	const store = useDataStore();
	const list = useRead(store.read(books));
	<ul>
		@for (const book of list; key book.id) {
			<li>
				<label>
					<input
						type="checkbox"
						checked={book.read}
						onChange={() =>
							void store.write(setRead, { id: book.id, read: !book.read }).catch(() => {})
						}
					/>
					{book.title as string}
				</label>
			</li>
		}
	</ul>
}

The checkbox uses onChange, which is right for a checkbox: the browser fires change the moment it is toggled. Octane's events are native, so for a text field you would use onInput for each keystroke (see Differences from React).

And the same component in React:

tsx
/** @jsxImportSource react */
import { useDataStore, useRead } from 'octane/data/react';
import { books, setRead } from './contracts.ts';

export function BookList() {
	const store = useDataStore();
	const list = useRead(store.read(books));
	return (
		<ul>
			{list.map((book) => (
				<li key={book.id}>
					<label>
						<input
							type="checkbox"
							checked={book.read}
							onChange={() =>
								void store.write(setRead, { id: book.id, read: !book.read }).catch(() => {})
							}
						/>
						{book.title}
					</label>
				</li>
			))}
		</ul>
	);
}

A few guarantees make optimistic edits safe to rely on:

  • Edits replay cleanly. Every edit is "set these fields", "insert this ID" or "delete this ID". When a newer value arrives from the server while an edit is still pending, the store re-applies the edit on top, and applying it to a value that already includes it changes nothing.
  • No half-applied replies. Every value one reply carries is adopted before any component re-renders, so no render sees part of a server transaction.
  • Writes wait for their create. If you create a book and then rename it before the create is confirmed, the rename waits. If the create is refused, the rename is refused too, without being sent.

When the server picks the ID of a new record (an auto-increment key, say), add serverId: (reply) => reply.id to the create. Until the reply arrives the record shows under a temporary ID, and later writes to it are sent with the server's ID.

When the server says no

A handler refuses a write by throwing a DataError with a short code. The code, and any data you attach, reach the browser unchanged:

ts
import { DataError } from 'octane/data';
import { createDataHandler } from 'octane/data/server';
import { addBook, type Book } from './contracts.ts';

const shelf: Book[] = [];

export const handler = createDataHandler({}, (handle) => [
	handle(addBook, (input, { operationId }) => {
		if (shelf.some((book) => book.title === input.title)) throw new DataError('duplicate');
		const book = { id: operationId, title: input.title, read: false };
		shelf.push(book);
		return book;
	}),
]);

On the browser, the write's promise rejects with that DataError, and the edit is already gone from the screen. A schema failure is a DataError too, with the code 'invalid' and the validator's messages in issues.

ts
import { DataError } from 'octane/data';
import { addBook } from './contracts.ts';
import { store } from './store.ts';

try {
	await store.write(addBook, { title: 'Kindred' });
} catch (error) {
	if (error instanceof DataError && error.code === 'duplicate') {
		console.log('That book is already on the shelf');
	}
}

When the reply is lost

Networks fail in an awkward place: after the server has applied a write but before its reply arrives. Retrying could apply the write twice; rolling back could hide a change that really happened. So the store does neither. The write's promise rejects with a DataUncertainError, and its edit stays on screen until you learn what happened.

Every write carries an operation ID, which your handler receives as call.operationId. If the handler records the IDs it has applied, a receipt read can answer "did this write land?" and store.settle puts the store right:

ts
import { DataUncertainError, read } from 'octane/data';
import { z } from 'zod';
import { setRead } from './contracts.ts';
import { store } from './store.ts';

export const receipt = read({
	name: 'books.receipt',
	input: z.object({ operationId: z.string() }),
	output: z.object({ applied: z.boolean() }),
});

export async function markRead(id: string) {
	try {
		await store.write(setRead, { id, read: true });
	} catch (error) {
		if (!(error instanceof DataUncertainError)) throw error;
		// Ask the server, then keep the edit or roll it back. Nothing is resent.
		const handle = store.read(receipt, { operationId: error.operationId });
		await handle.refresh();
		const snapshot = handle.getSnapshot();
		if (snapshot.status === 'ready') store.settle(error.operationId, snapshot.value!.applied);
	}
}

Recording operation IDs also makes your handler safe to call twice with the same write: check the set before applying. Persistence shows how the store can do this lookup for you, even after the page reloads.

Views and pagination

A plain read returns whatever the server sends. That works until an edit should move a row: marking a book read should move it out of "Unread" and into "Finished", in the right sorted position, right away. The server could send both lists again, but the browser already knows the rule.

A view is a list defined by a rule over an entity: which records belong (where) and in what order (order). The store evaluates that rule itself, over every copy of the entity it holds, so an edit moves a record into, out of or across views in the same moment.

ts
import { entity, view } from 'octane/data';
import { z } from 'zod';

const bookShape = z.object({ id: z.string(), title: z.string(), read: z.boolean() });
export const Book = entity('book', bookShape);

export const unread = view({
	name: 'books.unread',
	of: Book,
	where: (book) => !book.read,
	order: [['title', 'asc']],
	limit: 50, // Rows shown when no window is asked for.
});

export const finished = view({
	name: 'books.finished',
	of: Book,
	where: (book) => book.read,
	order: [['title', 'asc']],
	limit: 50,
});

order lists sort keys, most significant first, and the entity's key breaks any remaining ties. max (default 500) caps how many rows one request may ask for.

The server serves a window of the view: limit records starting at position offset, filtered by the same rule and sorted in exactly the same order. The contract carries the rule, so the handler can reuse it:

ts
import { createDataHandler } from 'octane/data/server';
import { unread, type Book } from './contracts.ts';

const shelf: Book[] = [];

export const handler = createDataHandler({}, (handle) => [
	handle(unread, (input) =>
		shelf
			.filter((book) => unread.view.where(book, input))
			.sort(unread.view.compare)
			.slice(input.offset, input.offset + input.limit),
	),
]);

In a real app this is a database query with the same WHERE and ORDER BY. The store loads a little more than the window it shows, so a row moving in from just below the fold appears without a request.

A view is a read, so useRead(store.read(unread)) shows its default window. To show a specific window, pass { offset, limit } to store.read, plus any parameters the view declares with an input schema.

Load a page at a time

For a list that grows as the user scrolls or presses "Show more", ask for a pages handle with pages(store, view, params?), and show it with usePages. It joins the pages loaded so far into one list, and it does not suspend.

tsrx
import { pages } from 'octane/data';
import { useDataStore, usePages } from 'octane/data/octane';
import { unread } from './contracts.ts';

export function UpNext() @{
	const list = usePages(pages(useDataStore(), unread));
	<section>
		<ul>
			@for (const book of list.value ?? []; key book.id) {
				<li>{book.title as string}</li>
			}
		</ul>
		@if (list.more) {
			<button type="button" disabled={list.loadingMore} onClick={() => void list.loadMore()}>
				Show more
			</button>
		}
	</section>
}

Each page is an ordinary window of the view, so pages share records with each other and with every other view, and an edit moves a row from one page to the next exactly as it moves rows within one. Outside a component, pages(store, view, params?, size?) returns the same list as a handle with getSnapshot, subscribe, loadMore and refresh.

Forms that work before JavaScript

A form is the most natural way to send a write, and the browser already knows how to submit one: as a native POST, with no JavaScript at all. octane/data lets the same form work both ways. Before the page hydrates, or with JavaScript turned off, it posts to the server natively. Once hydrated, it submits through the store instead, with the write's optimistic edit.

Give the write a form option that lists the fields a form submits and how to read each one:

ts
import { entity, write } from 'octane/data';
import { z } from 'zod';

const bookShape = z.object({
	id: z.string(),
	title: z.string().trim().min(1, 'Enter a title'),
	read: z.boolean(),
});
export const Book = entity('book', bookShape);

export const addBook = write({
	name: 'books.add',
	input: bookShape.pick({ title: true }),
	output: bookShape,
	creates: Book,
	record: (book) => book,
	form: { title: 'text' },
});

Each field is one of 'text', 'secret' (text a refused form never sends back to the page, such as a password), 'number', 'boolean' (exactly "true" or "false", as a hidden field writes it), or 'checkbox' (true when present, false when absent). Any field the write does not declare is refused.

In a component, useForm returns everything the <form> needs:

tsrx
import { useForm } from 'octane/data/octane';
import { addBook } from './contracts.ts';

export function AddBook() @{
	const add = useForm(addBook);
	const message = add.issues.title ?? (add.error ? 'Could not add that book' : '');
	<form {...add.attrs}>
		{add.hidden()}
		<input name="title" aria-label="Book title" defaultValue={add.values.title ?? ''} />
		<button type="submit" disabled={add.pending}>
			Add
		</button>
		@if (message) {
			<p role="alert">{message as string}</p>
		}
	</form>
}
  • attrs goes on the <form>: method="post", and an onSubmit that takes over once the page is hydrated.
  • hidden(values?) renders hidden inputs that name the write and the form, plus any input values the user does not type, such as a row's ID.
  • pending, values, issues and error describe the latest submission. After a refusal, values holds what was typed so the field can show it again, and issues holds the first message for each field (one about the whole input is under '').
  • submit(eventOrFormData) submits through the store yourself, if you need to run code around it.

For one form per row, pass a key: useForm(removeBook, book.id) gives each row its own pending state and messages.

On the server, call handler.forms before rendering a page. When the request is a form's native POST, it runs the write. An accepted submission gets a 303 redirect back to the page. A refused one renders the page again, with the form's values and messages carried to the browser through the store's seed.

ts
// server.ts
import { createDataHandler } from 'octane/data/server';
import { addBook } from './contracts.ts';
import { addToShelf } from './db.ts';

export const handler = createDataHandler(
	{
		context: (request) => ({ account: request.headers.get('x-account') ?? 'guest' }),
		// Signs each page's forms. Keep it in server-only configuration.
		secret: process.env.FORM_SECRET!,
		// The account a request acts for: a form signed for one account is refused for another.
		grant: (context) => context.account,
	},
	(handle) => [handle(addBook, (input, { account }) => addToShelf(account, input.title))],
);
ts
// page.ts — the server route that renders a page and accepts its form posts.
import { handler } from './server.ts';
import { renderPage } from './render.ts';

export async function page(request: Request): Promise<Response> {
	const forms = await handler.forms(request);
	if (forms.response) return forms.response; // An accepted POST: redirect back.
	const html = await renderPage(request, forms.form);
	return new Response(html, { status: forms.status, headers: { 'content-type': 'text/html' } });
}

Pass forms.form to the page's store as createDataStore({ transport, form }), as the server rendering section shows. Each page's forms are signed for its account, and each render's operation IDs are new, so a page that is submitted twice is applied once.

Serve your contracts

createDataHandler(options, define) builds the server side. define receives handle, which pairs a contract with the function that serves it, and defineHandler, which serves a whole module's contracts, and returns the list of handlers. Two handlers with the same name are an error.

ts
import { DataError } from 'octane/data';
import { createDataHandler } from 'octane/data/server';
import { books, setRead } from './contracts.ts';
import { accountFrom, shelfFor } from './accounts.ts';

export const handler = createDataHandler(
	{
		// Runs for every request: authenticate it and return what handlers need.
		context: (request) => {
			const account = accountFrom(request);
			if (!account) throw new DataError('unauthorized');
			return { account, shelf: shelfFor(account) };
		},
		// Other origins that may call this endpoint. The request's own is always allowed.
		allowedOrigins: ['https://admin.example.com'],
	},
	(handle) => [
		handle(books, (_, { shelf }) => shelf),
		handle(setRead, (input, { shelf }) => {
			const book = shelf.find((item) => item.id === input.id);
			if (!book) throw new DataError('missing');
			book.read = input.read;
			return book;
		}),
	],
);

Every handler receives the validated input and a call object. It holds what your context returned, plus:

  • call.request: the incoming Request.
  • call.signal: aborts if the client goes away.
  • call.operationId: the client's ID for a write (empty for a read).
  • call.read(contract, input): run another read in process, under the same context.

context also receives a second argument, caller, with the viewer your server middleware authenticated, when the request came through a server function or a framework middleware.

Serve a whole module

define also receives defineHandler, which serves every contract a module exports from one object keyed by export name. Import the module as a namespace and give each contract its function:

ts
import { createDataHandler } from 'octane/data/server';
import * as Shelf from './contracts.ts';

export const handler = createDataHandler({ context }, (_, defineHandler) =>
	defineHandler(Shelf, {
		books: (input, { shelf }) => shelf.slice(input.offset, input.offset + input.limit),
		stats: (_, { shelf }) => ({ total: shelf.length }),
		setRead: (input, { shelf }) => markRead(shelf, input.id, input.read),
	}),
);

Each function is typed as handle would type it for that contract, so input, call and the return value come from the contract and your context. The object needs exactly one function for each read, view, write and feed the module exports: a missing, misspelled or extra key fails to compile, so adding a contract to the module points you at the server code that must serve it. Other exports, such as an entity or a schema, need none. defineHandler returns the same handlers handle makes, so you can spread it next to them: [...defineHandler(Shelf, { … }), handle(other, run)].

To share one function between contracts, write a small factory and call it once per key, such as books: windowOf(Shelf.books). The Implementation<C, T> type names the function that serves contract T under context C.

Serve through a server function

The pattern from your first read is the recommended one in an Octane app. handler.serve(request, call) answers one store request sent through a module server function, and serverTransport(fn) sends it. The call runs under your app's origin checks and middleware, and during a server render the same call runs in process with no network hop at all.

Serve over HTTP

The handler is also an ordinary fetch-style function, (request: Request) => Promise<Response>, so you can mount it at a URL in any server that speaks Request and Response: Bun, Deno, Workers, Node through an adapter, or a framework route.

ts
// A route in any Request/Response server.
import { handler } from './server.ts';

export function POST(request: Request): Promise<Response> {
	return handler(request);
}

Then point the browser's store at that URL with httpTransport:

ts
import { createDataStore, httpTransport } from 'octane/data';

export const store = createDataStore({
	transport: httpTransport('/api/data', { headers: { 'x-app-version': '1' } }),
});

Both transports encode requests with devalue, so dates, maps and sets survive the trip. A response that is not a reply, such as a proxy's error page, counts as a lost reply, never as a refusal.

You can also write your own transport. It is one function, (request, signal) => Promise<DataReply>. Resolving with a reply is definitive; rejecting means the outcome is unknown.

Server rendering and hydration

When a page is rendered on the server, its reads should be loaded there, and the browser should start with those values instead of loading them again. octane/data does this with a seed: a snapshot of the server store's values, written into the page as JSON.

  • Request storecreateDataStore, one per request
render
  • HTMLevery useRead resolved
dehydrate()
  • Seed<script type="application/json">
seed option
  • Browser storeadopts values on first read
hydrate
  • Hydrated pageno requests
A server render loads its reads, writes them into the page as a seed, and the browser's store starts from it.

On the server, create a store for each request. Its transport calls the handler in process, under the request's account. Render, take the seed with dehydrate(), then retire() the store:

ts
// render.ts — runs on the server for each page request.
import { createDataStore, serverTransport } from 'octane/data';
import { prerender } from 'octane/static';
import { App } from './App.tsrx';
import { data } from './data.tsrx';

export async function renderPage(template: string): Promise<string> {
	const store = createDataStore({ transport: serverTransport(data) });
	try {
		const { html, css } = await prerender(App, { store });
		const seed = store.dehydrate();
		return template
			.replace('<!--app-html-->', () => css + html)
			.replace(
				'<!--app-seed-->',
				() => `<script type="application/json" id="data-seed">${seed}</script>`,
			);
	} finally {
		store.retire(); // A request's store ends with its request.
	}
}

prerender from octane/static waits for every suspended read, so the HTML arrives complete. dehydrate() returns text that is safe to place inside a <script type="application/json">; call it once the render is complete. If your server serves contracts over HTTP instead of a server function, handler.transport(request) gives the same in-process transport for a Request.

In the browser, read the seed and pass it to the store before hydrating:

ts
// main.ts — the browser entry.
import { hydrateRoot } from 'octane';
import { createDataStore, serverTransport } from 'octane/data';
import { App } from './App.tsrx';
import { data } from './data.tsrx';

const seed = document.getElementById('data-seed')?.textContent ?? null;
const store = createDataStore({ transport: serverTransport(data), seed });

hydrateRoot(document.getElementById('root')!, App, { store });

The store adopts each seeded value the first time a component asks for it, so hydration finds the server's value without waiting or loading. A seeded value keeps its age: fresh and expire count from when the server loaded it, not from when the page opened. If you know how long the page sat in a cache, pass that as seedAge in milliseconds.

React works the same way: render with prerender from react-dom/static, call store.dehydrate(), and pass the seed to the browser's store before hydrateRoot from react-dom/client. The seed format belongs to the store, not to the renderer, so an Octane page and a React page can share one server.

Realtime feeds

So far the browser only learns about a change when it asks. A feed delivers changes as they happen: another tab marks a book read, another user adds one, a background job finishes, and every page listening updates without loading anything.

  • Your eventsdatabase, queue, pub/sub
yield frames
  • Feed handlervalidates each event; cursor, gap
stream
  • serverListener
  • webSocketListener
  • pubSubListener
listen
  • Storefeed.changes → put, remove, read
notify
  • Every readholding those records
A feed: the server's handler yields events, a listener carries them, and the store applies each one to every read that holds the records.

Describe the events

A feed contract has an event schema and a changes function that says what each event means to the store:

ts
import { entity, feed, read } from 'octane/data';
import { z } from 'zod';

const bookShape = z.object({ id: z.string(), title: z.string(), read: z.boolean() });
export const Book = entity('book', bookShape);

const counts = z.object({ total: z.number().int(), read: z.number().int() });
export const stats = read({ name: 'books.stats', output: counts, tags: ['shelf'] });

const shelfEvent = z.discriminatedUnion('kind', [
	z.object({ kind: z.literal('put'), book: bookShape, counts }),
	z.object({ kind: z.literal('removed'), id: z.string(), counts }),
]);
export type ShelfEvent = z.infer<typeof shelfEvent>;

export const shelfChanges = feed({
	name: 'books.changes',
	event: shelfEvent,
	changes: (event) => [
		event.kind === 'put' ? { put: Book, value: event.book } : { remove: Book, id: event.id },
		{ read: stats, value: event.counts },
	],
	// If events may have been missed, these reads reload.
	covers: ['book', 'shelf'],
});

A change is one of four things:

  • { put: Entity, value }: this record, as the server now holds it. Every read and view holding it shows it, and views re-sort.
  • { remove: Entity, id }: this record was deleted; it leaves every list.
  • { read: Read, input?, value }: this read's whole new value, such as a count.
  • { invalidates: [...] }: reads with these tags reload, for a change the event does not carry.

covers names the tags and entities the feed keeps current. If the store may have missed events (the connection dropped, or the server reports a gap), it marks those reads stale and the ones on screen reload, so nothing stays wrong.

Serve the events

A feed's handler is an async generator. It yields frames for one connection until call.signal aborts:

  • { event, id?, cursor? }: an event. id lets the store drop a duplicate, and cursor is the position to resume from after it.
  • { gap: true }: events were missed, such as a cursor too old to resume from.
  • { cursor } with no event: "the connection is open, and this is where it starts."
ts
import { createDataHandler } from 'octane/data/server';
import { shelfChanges } from './contracts.ts';
// Your app's change log: each change has a position and its event.
import { changesAfter, latestPosition, nextChange } from './changes.ts';

export const handler = createDataHandler({}, (handle) => [
	handle(shelfChanges, async function* (_, { cursor, signal }) {
		let seen = cursor === null ? latestPosition() : Number(cursor);
		yield { cursor: String(seen) }; // Open, from here.
		while (!signal.aborted) {
			for (const { position, event } of changesAfter(seen)) {
				seen = position;
				yield { event, id: String(position), cursor: String(position) };
			}
			await nextChange(signal);
		}
	}),
]);

When the client reconnects, call.cursor is the last cursor it saw, so the handler can resume right after it. Every event is validated against the feed's schema before it leaves the server.

Connect the store

The store's listen option says how feeds connect. There are three listeners, for three kinds of servers:

  • serverListener(fn) streams each connection through a module server function that returns handler.listen(request, call). A connection lasts up to 25 seconds, within a server call's limit, and the store reconnects from its cursor.
  • webSocketListener(url) opens a WebSocket per connection. On the server, handler.accept(socket, request, caller?) serves it, with no time limit, and refuses sockets from other origins.
  • pubSubListener(subscribe) uses a message broker the page already has. A broker without history has no cursor, so after a dropped subscription the store reloads what the feed covers.
tsrx
// data.tsrx
module server {
	import type { ServerCallContext } from 'octane/server';
	import { handler } from './server.ts';

	export async function data(request: string, call: ServerCallContext) {
		return handler.serve(request, call);
	}

	export function events(request: string, call: ServerCallContext) {
		return handler.listen(request, call);
	}
}

import { data, events } from 'server';

export { data, events };
ts
// store.ts
import { createDataStore, serverListener, serverTransport } from 'octane/data';
import { data, events } from './data.tsrx';

export const store = createDataStore({
	transport: serverTransport(data),
	listen: serverListener(events),
});

Listen while a component is mounted

A feed connects only while something listens. useFeed listens for as long as the component is mounted, and returns the connection's status: 'connecting', 'live', or 'offline' while it waits to retry. Every component listening to the same feed shares one connection.

tsrx
import { listen } from 'octane/data';
import { useDataStore, useFeed } from 'octane/data/octane';
import { shelfChanges } from './contracts.ts';

const LABELS = { connecting: 'Connecting…', live: 'Live', offline: 'Reconnecting…' };

export function LiveBadge() @{
	const feed = useFeed(listen(useDataStore(), shelfChanges));
	<p data-feed={feed.status}>{LABELS[feed.status] as string}</p>
}

Outside components, listen(store, feed, params?) returns the same handle with getSnapshot and subscribe; subscribing connects, and the last unsubscribe disconnects. A failed connection retries with growing, jittered delays, up to 30 seconds apart.

If your app already has its own subscription, receive(store, changes) applies a list of changes exactly as a feed event would. It does not validate them, so pass only values a schema has already checked.

Feeds and server rendering

There is a gap between a server render and the browser's first feed connection. An event that happens in between would be lost. To close it, pass the page's feeds to the server's store:

ts
import { createDataStore, serverTransport } from 'octane/data';
import { shelfChanges } from './contracts.ts';
import { data } from './data.tsrx';

const store = createDataStore({
	transport: serverTransport(data),
	feeds: [shelfChanges],
});

Before its first read, the server's store asks each feed for its current position, and the seed carries it. The browser's feed then resumes from just before the page's values loaded, so a change made during the render still arrives. For this, a feed handler yields its position first when call.cursor is null, as the example above does.

Persistence and offline

A reload normally starts with an empty store. With persist, the store saves confirmed values to the device, and on its next start shows them straight away, with their age, just like a seed: a fresh value needs no request, and a stale one shows while it reloads.

ts
import { autoBudget, createDataStore, indexedDBStorage, serverTransport } from 'octane/data';
import { data } from './data.tsrx';

import { session } from './session.ts';

export const store = createDataStore({
	transport: serverTransport(data),
	persist: {
		storage: indexedDBStorage({ name: 'my-app' }),
		// The account the store acts for; each account's values are kept apart.
		partition: session.account,
		// A random per-user secret from the signed-in session, never a password.
		key: session.cacheKey,
		// How much to keep, in characters of saved text. 'auto' sizes it by device.
		budget: (device) => autoBudget(device),
	},
});

Saved data is private and tidy by design:

  • Encrypted. Every value is encrypted with key, and every name storage sees is a keyed hash. People who share a browser can neither read nor list each other's values. Take the key from the signed-in session, such as a per-user secret your server issues at sign-in. It can be a string of at least 128 random bits, bytes, or a non-exportable CryptoKey.
  • Only confirmed values. Nothing pending is saved, so a restart never shows an edit the server refused.
  • Within budget. After each save, expired values go first, then the least recently used, down to 90% of the budget. Values on screen are never purged. autoBudget gives 1 MB on small devices, 2 MB up to 4 GB of memory, 5 MB above, halved when the user asks to save data, and never more than 1% of the storage quota.
  • Versioned. A value saved under a different output schema shape, or a different version on its read, is discarded unread.

Set persist: false on a read whose value should never touch the disk, such as one holding secrets. Change a read's version when a saved value would now mean something else.

indexedDBStorage is the browser adapter. memoryStorage() keeps everything in memory, for tests and hosts without persistent storage. To persist anywhere else, such as OPFS or native storage, implement the PersistentStorage interface: four methods (index, get, commit, clear) that only ever see hashed names and encrypted text.

Writes that survive a reload

What about a write the user made just before closing the tab, or while offline? Add a journal. Each listed write is saved before it is sent, and leaves the journal only once its outcome is known.

ts
import { createDataStore, indexedDBStorage, read, serverTransport } from 'octane/data';
import { z } from 'zod';
import { addBook, removeBook, setRead } from './contracts.ts';
import { data } from './data.tsrx';

import { session } from './session.ts';

export const receipt = read({
	name: 'books.receipt',
	input: z.object({ operationId: z.string() }),
	output: z.object({ applied: z.boolean() }),
});

export const store = createDataStore({
	transport: serverTransport(data),
	persist: {
		storage: indexedDBStorage(),
		partition: session.account,
		key: session.cacheKey,
		journal: { writes: [addBook, setRead, removeBook], receipt },
	},
});

After a restart, the store finds the unfinished write, shows its edit again, and reads its receipt to learn whether the server applied it. It then keeps or rolls back the edit. It never sends the write twice. A create whose server assigns IDs needs the created record as value in an applied receipt.

Signing out

When the account changes, call store.retire(). It clears every value in memory at once, deletes the account's saved values, and ignores any reply still on its way. Later writes throw DataRetiredError; later reads stay pending without loading, so a component that still holds the old store shows nothing rather than failing. Then create a new store for the new account.

ts
import { createDataStore, serverTransport, type DataStore } from 'octane/data';
import { data } from './data.tsrx';

let store: DataStore = createDataStore({ transport: serverTransport(data) });

export async function switchAccount(): Promise<DataStore> {
	const old = store;
	store = createDataStore({ transport: serverTransport(data) });
	await old.retire(); // Resolves once its saved values are deleted.
	return store;
}

Give the new store to your DataProvider and key the subtree by account, so nothing from the old account carries over. When tabs share an owner, retiring one tab's store signs the account out in every tab; see One account per browser.

Sharing across tabs

Open an app in two tabs and, normally, each tab has its own store. They load the same reads twice, hold two feed connections, and only see each other's edits after a reload or a feed event.

With a shared owner, the tabs of one account share a single owner, running in a SharedWorker. Each tab keeps a replica: an ordinary store that renders, evaluates views and shows its own edits instantly, exactly as before. The owner makes every request and holds every feed connection, and tells each replica what the others did. So every tab shows the same values, the same pending edits and the same rollbacks, and equal reads from several tabs are one request.

  • Tab 1replica store
  • Tab 2replica store
  • Tab 3replica store
message ports
  • Data workerserveDataOwner: one owner per account
transport + listen
  • ServercreateDataHandler
Tabs keep replicas; one SharedWorker owner per account makes every request and holds every feed connection.

It takes three steps.

1. Write the worker module. It calls serveDataOwner with the transport and listener a tab would otherwise use. If your app keeps an encrypted cache, give the worker that too, as a function of the signed-in account: the worker is the only one that writes the account's cache and write journal, so every tab shares one cache and one size limit.

ts
// worker.ts
import { indexedDBStorage, serveDataOwner, serverListener, serverTransport } from 'octane/data';
import { data, events } from './data.tsrx';
import { cacheKeyFor } from './cache-key.ts';

serveDataOwner({
	transport: serverTransport(data),
	listen: serverListener(events),
	persist: (account) => ({
		storage: indexedDBStorage(),
		partition: account,
		key: () => cacheKeyFor(account),
	}),
});

The worker gets the cache key itself, here from a hypothetical cacheKeyFor helper that fetches the signed-in user's secret, so the key never has to be handed over from a page.

2. Tell the build about it. The dataWorker option emits the module at a fixed path, /_octane/data-worker.js, which never changes between deploys, so every tab constructs the same worker. With Vite, pass it to the compiler plugin from octane/compiler/vite:

ts
// vite.config.ts
import { defineConfig } from 'vite';
import { octane } from 'octane/compiler/vite';

export default defineConfig({
	plugins: [octane({ dataWorker: 'src/worker.ts' })],
});

The development server serves the worker at the same path. With Rspack, the option is the same on @octanejs/rspack-plugin:

ts
// rspack.config.mjs
import { OctaneRspackPlugin } from '@octanejs/rspack-plugin';

export default {
	plugins: [new OctaneRspackPlugin({ dataWorker: './src/worker.ts' })],
};

With Rspack the worker is emitted as the octane-data-worker entry; leave that entry out of your pages' HTML.

3. Connect each tab. sharedOwner(account) connects to the owner every tab signed in as that account uses. Pass the same account ID your server authenticates, such as the signed-in user's ID. Then give the result to createReplica, along with exactly the options you would give an ordinary store, plus one more: contracts, a list of every read, write and feed your app uses.

ts
import { createReplica, serverListener, serverTransport, sharedOwner } from 'octane/data';
import { addBook, books, finished, removeBook, setRead, shelfChanges, stats } from './contracts.ts';
import { data, events } from './data.tsrx';

const contracts = [books, finished, stats, addBook, setRead, removeBook, shelfChanges];

export function openStore(account: string, seed: string | null) {
	return createReplica(sharedOwner(account), {
		transport: serverTransport(data),
		listen: serverListener(events),
		seed,
		contracts,
	});
}

Why the ordinary options? Because sharing is not always possible, and the tab should keep working when it isn't. The replica holds on to them, and quietly becomes an ordinary store, using that transport and listen itself, when:

  • sharedOwner returned null, because the browser has no SharedWorker or would not start one;
  • the owner turns this tab away because it comes from a different deploy (more on that below);
  • the owner cannot record the signed-in account, for example because IndexedDB is unavailable in the worker;
  • the owner stopped answering and a new one could not be started.

Why contracts? Two reasons. The replica uses the writes in it to show other tabs' edits as they happen. And the owner uses the whole list to check that every tab sharing it was built from the same contracts. A replica still hydrates from the page's seed, so hydration never waits for the worker.

A page's seed can be newer than what your other tabs hold, or older, if the page came from a cache. So after hydrating, a tab offers its seeded values to the others, along with when they were loaded. Whichever value is newer wins, in every tab, without loading anything again. And if the page's live feed position differs from the one the worker is connected at, the tab may have missed events in between, so it reloads what that feed covers.

You can also run an owner without a worker. createDataOwner({ transport, listen }) returns an owner whose connect(port) serves a replica over any MessagePort, such as one end of a MessageChannel. That is useful in tests, and for sharing one owner between several roots in one page.

One account per browser, in every tab

A shared owner has to be careful about accounts. Picture two tabs open as Ada. In one, Ada signs out and Grace signs in. The other tab must not keep showing Ada's shelf, and nothing Ada's tab had in flight may arrive in Grace's. That is true even for a tab the browser froze in the background, which hears nothing until it wakes.

So the data worker fences accounts. The origin has one signed-in account at a time. The worker records it in IndexedDB, with a generation number that goes up on every sign-in and sign-out, before any tab hears about it, and announces each change to the other workers on a BroadcastChannel. serveDataOwner turns this on for you, and you do not call anything extra:

  • Signing in as another account signs the old one out everywhere. When a tab connects with sharedOwner('grace') while Ada is recorded, Grace is recorded, and every tab of Ada's is retired: its values clear, its pending requests reject with DataRetiredError, and its feeds close.
  • Signing out reaches every tab. Calling store.retire() on a replica signs its account out in every tab, not just this one. The tab keeps asking until the owner confirms, so a busy or restarting worker cannot lose a sign-out.
  • A late sign-out never undoes a newer sign-in. A sign-out applies only if the generation it was made under is still the recorded one.
  • Frozen tabs wake up safe. When the browser freezes a tab, its reads show as pending. When it resumes, it asks its owner whether its account is still signed in, and shows its values again only if it is. Otherwise it is retired.

In practice, switching accounts in a tab looks like it does without sharing: open the new account's store, and give it to your DataProvider. The old account's other tabs are retired for you.

ts
import { type DataStore } from 'octane/data';
import { openStore } from './store.ts';

export function switchAccount(current: DataStore, next: string): DataStore {
	// Recording `next` retires every tab of the old account, this one's store included.
	const store = openStore(next, null);
	void current.retire();
	return store;
}

createDataOwner takes the same switch as fence: true when you host an owner yourself; without it, the owner serves every replica that connects.

Different deploys, and crashes

A SharedWorker outlives any one page. If you deploy while a user has tabs open, a tab of the new build may connect to a worker that an old tab started. That is fine as long as both builds agree on the data, so the owner checks. The first tab to connect decides: its contracts (each contract's kind, name, version, and output schema shape) become the owner's. A tab whose contracts differ is turned away, and carries on as an ordinary store. Once every old tab closes, the old worker ends, and the next tab starts the new one.

Workers can also crash, and tabs can disappear without saying goodbye. So the tabs and the owner check on each other every five seconds:

  • When the owner goes quiet, a tab hides its values (its reads show as pending), starts the worker again, and introduces itself to the new owner. Reads the old owner never answered are asked again. A write that was on its way may already have reached the server, so it is never sent twice: it becomes uncertain, and you settle it from its receipt as with any lost reply.
  • When a tab goes quiet, the owner stops holding feed connections for it. Any write the tab sent keeps going.

One cache for every tab

Give serveDataOwner a persist function, and the worker becomes the only place the account's encrypted cache and write journal are written. Each shared tab's store still asks for saved values and saves what changed, exactly as an ordinary store would, but it asks the worker:

  • One cache, one size limit. Values every tab loads are saved once, and the cache's budget applies to the account as a whole, not to each tab.
  • No tab overwrites another. Only the worker writes, so a value one tab saved is never replaced by an older copy from another.
  • A reloaded tab starts from the cache. It asks the worker for the values it needs, and shows fresh ones without loading them.
  • One journal, recovered once. Durable writes are journaled by the worker. After a restart, only the first tab to start recovers them, so each write's receipt is looked up once.
  • Signing out clears it. When the account signs out, or another signs in, the worker clears the account's cache.

A tab only uses the persist options you gave createReplica when it carries on as an ordinary store. Point them at the same storage, partition and key, and such a tab reads the same cache.

Writes in flight when the worker crashes

A write that was on its way when the worker crashed may or may not have reached your server, so it becomes uncertain, and its edit stays on screen. If the write is in your journal, you don't need to do anything else. Once a tab reconnects to the new worker, it asks your receipt read whether the server applied the write, then keeps the edit or rolls it back. The write is never sent again.

Every tab showing that write does the same, but the worker asks your server only once and gives each tab the same answer, so all of them end up agreeing. A write that isn't in the journal stays uncertain until you settle it from its receipt yourself, as with any lost reply.

Octane and React, one store

The store owns every value, pending edit and rollback; a binding only subscribes to it. So an Octane component and a React component given the same store always agree: an edit made in one shows in the other in the same moment, and a rollback reaches both.

  • Octane components.tsrx
  • React components.tsx
hooks
  • octane/data/octane
  • octane/data/react
useSyncExternalStore
  • One DataStorevalues, edits, rollbacks
Both bindings read one store, so an Octane page and a React island show the same values and edits.

Both bindings export the same names with the same behavior, so moving a component between renderers changes only the import:

Hook or componentWhat it does
DataProviderPuts a store in context for the components below it: <DataProvider store={store}>.
useDataStore()Returns the store from context: read gives the handles below, write sends a write, and so on.
useRead(store.read(read, input?))Returns the value, suspending until the first one arrives; with { suspend: false }, the live snapshot.
usePages(pages(store, view, params?))Returns a view's pages so far as one list, with more, loadingMore and loadMore.
useForm(write, key?)Returns a native form for a write that declares form fields.
useFeed(listen(store, feed, params?))Listens to a feed while mounted, and returns its connection status.

The only difference is the host renderer's rules. In Octane, hooks are keyed by where they are written, not by call order, so a useRead may sit behind a condition or after an early return. In React, the usual rules of hooks apply.

An Octane page with a React island

ReactCompat from octane/react hosts real React components inside an Octane page. Pass the island the same store, and give it its own React DataProvider:

tsx
/** @jsxImportSource react */
// UpNext.tsx — a React component.
import type { DataStore } from 'octane/data';
import { DataProvider, useDataStore, useRead } from 'octane/data/react';
import { setRead, unread } from './contracts.ts';

function UpNextList() {
	const store = useDataStore();
	const list = useRead(store.read(unread), { suspend: false }).value ?? [];
	return (
		<ul>
			{list.map((book) => (
				<li key={book.id}>
					{book.title}{' '}
					<button
						type="button"
						onClick={() => void store.write(setRead, { id: book.id, read: true }).catch(() => {})}
					>
						Mark read
					</button>
				</li>
			))}
		</ul>
	);
}

export function UpNext(props: { store: DataStore }) {
	return (
		<DataProvider store={props.store}>
			<UpNextList />
		</DataProvider>
	);
}
tsrx
// Page.tsrx — the Octane page that hosts it.
import type { DataStore } from 'octane/data';
import { DataProvider } from 'octane/data/octane';
import { ReactCompat } from 'octane/react';
import { BookList } from './BookList.tsrx';
import { UpNext } from './UpNext.tsx';

export function Page(props: { store: DataStore }) @{
	<DataProvider store={props.store}>
		@try {
			<BookList />
		} @pending {
			<p>Loading…</p>
		}
		<ReactCompat>
			<UpNext store={props.store} />
		</ReactCompat>
	</DataProvider>
}

Each renderer has its own context, so the island needs its own provider; the store is the same object. Marking a book read from the React island moves it in the Octane list in the same moment. The same works the other way round with OctaneCompat, which hosts Octane components inside a React app. See React interop.

Errors

octane/data keeps three outcomes apart, because each needs a different response from your app:

ErrorWhat it meansWhat to do
DataErrorA definitive refusal. The operation did not happen. code says why, data carries details, issues lists validation messages.Show the reason. A write's edit has already been rolled back.
DataUncertainErrorA write may have reached the server, but its reply was lost. Its edit stays on screen.Look up its receipt with operationId, then call store.settle.
DataRetiredErrorThe store was retired, such as after sign-out.Use the new account's store.

A complete example

Shelf is a reading list built on octane/data that uses everything on this page. One set of contracts and one server render three pages: an Octane page, a React page, and an Octane page hosting a React island, built with Vite or Rspack. Try it with JavaScript turned off, with the network offline, with a lost reply, and in two tabs at once.

API reference

octane/data

ExportWhat it is for
readDescribe a read: name, input, output, entities, tags, fresh, keep, expire, persist, version.
writeDescribe a write: creates, patches or removes an entity, with draft, serverId, record, form and invalidates.
entityName a record type and its key, so every read holding a record shares its fields.
viewDescribe a list by a rule over an entity: where, order, limit, max.
feedDescribe server events and the changes each one makes, with the reads it covers.
DataErrorA definitive refusal, with code, data and issues.
createDataStoreCreate a store: transport, seed, seedAge, form, persist, listen, feeds.
DataUncertainErrorA write whose outcome is unknown; settle it by operationId.
DataRetiredErrorThrown by a retired store.
httpTransportSend requests to a handler mounted at a URL.
serverTransportSend requests through a module server function that calls handler.serve.
serverListenerListen to feeds through a module server function that calls handler.listen.
webSocketListenerListen to feeds over a WebSocket served by handler.accept, or an existing socket service.
pubSubListenerListen to feeds through a message broker the page already uses.
listenListen to a feed from plain code; returns a handle with getSnapshot and subscribe.
receiveApply changes from your own subscription, as a feed event would.
pagesRead a view a page at a time from plain code.
indexedDBStoragePersistent storage in IndexedDB.
memoryStorageStorage in memory, for tests and hosts without persistent storage.
autoBudgetA storage budget sized by the device's memory, data saver and quota.
createDataOwnerAn owner that serves replicas over message ports.
createReplicaA store whose requests and feeds go through an owner.
sharedOwnerConnect to the account's owner in the app's data worker, or null without SharedWorker.
serveDataOwnerServe an owner to every tab that connects: what the data worker module calls.
contractIdentityWhat createReplica compares to tell whether two builds can share an owner; octane/data/manifest exports it as identity.

A store has these methods: read, prefetch, write, form, invalidate, settle, publish, forget, retire and dehydrate, plus the retired flag.

octane/data/server

ExportWhat it is for
createDataHandlerServe contracts. Options: context, allowedOrigins, secret, grant. define receives handle and defineHandler. Returns a fetch-style handler with transport, serve, listen, accept and forms.

octane/data/octane and octane/data/react

ExportWhat it is for
DataProviderProvide a store to the components below it.
useDataStoreRead the store from context.
useReadShow a read handle's value, suspending until the first one arrives, or its snapshot with { suspend: false }.
usePagesShow a pages handle: a view a page at a time.
useFormRender a native form for a write.
useFeedListen to a feed handle while mounted.