Skip to content

API reference > @kontsedal/olas-react

olas-react package ​

Functions ​

Function

Description

createOlasContext(displayName)

Mint an independent context bound to a specific Api type. Use when:

  • You have two or more Olas roots in the same React tree and need to route consumers to the right one (the default useRoot<Api>() casts unchecked across them). - You want the api type baked in so call sites don't have to repeat useRoot<MyApi>().
ts
type AuthApi = { user: ReadSignal<User|null>; signIn: ... }
const { Provider, useRoot } = createOlasContext<AuthApi>('AuthRoot')

<Provider root={authRoot}><App /></Provider>

function Header() {
  const { user } = useRoot()      // user is ReadSignal<User|null>
}

Each call returns a *new* React context. The default <OlasProvider> /useRoot() remain available for single-root apps.

createStreamingHydrator(options)

Server-side hydrator. Captures every committed write — a fetch resolving, a canonical write or replace, an optimistic layer committed as server truth — through the plugin onWrite hook, then serializes captured entries to inline <script> tags via flush(). An infinite query's entry carries its pages and their pageParams, so the client continues paging from where the server stopped.

Hydration, optimistic writes and rollbacks are not captured: the first is data the client already has, and the other two are a guess the server never confirmed and its undoing. A 'commit' is captured, because it is the data the server rendered once no guess is left.

The payload is serialized with serializeForScript: JSON.parse over a fully escaped string, so query data cannot end the script, form markup, or turn a __proto__ key into a prototype on the client.

createStreamingTransform(flush)

Web-Streams transformer that interleaves flush() output into an HTML stream. Wraps renderToReadableStream (or any otherReadableStream<Uint8Array> producing HTML), so each batch of entries that resolved reaches the client as the stream goes.

A batch goes only where React's hydration never sees it: directly inside<body> for a whole document, or at the top level for a fragment React hydrates into a container, outside every <Suspense> boundary's content, and right after a tag or a comment. So never inside a tag, an attribute, a comment, raw text or a text node, and never inside an element React hydrates. React writes in fixed-size chunks, which can end anywhere, so the transform tracks the markup it passes through (htmlBoundary). Each chunk gets the pending batch at its first such point, so data goes out before the markup that reads it. A chunk with no such point holds the batch; entries keep collecting in the hydrator meanwhile, so the held batch goes out whole.

Pipe React's own stream through the transform, and put any HTML template of your own around its output. A template inside the transform puts the page inside an element the transform cannot tell from React's, and every batch waits for the end.

Works in Node, the Edge runtime, Cloudflare Workers, Deno, and the browser — anywhere Web Streams are available.

tsx
const { plugin, flush } = createStreamingHydrator()

// One root per request, carrying the plugin, and the SAME root renders.
// A plugin on a root nothing renders captures no entries. The root needs a
// query engine, or there is no cache to capture. On the server, render
// through `OlasProvider`: a `HydrationBoundary` builds its root in render
// and disposes it in an effect, and effects do not run on the server.
const root = createRoot(appDef, { deps, queries: queryEngine(), plugins: [plugin] })
const stream = await renderToReadableStream(
  <OlasProvider root={root}>
    <App />
  </OlasProvider>,
  { bootstrapScriptContent: OLAS_BOOTSTRAP_SCRIPT },
)
const interleaved = stream.pipeThrough(createStreamingTransform(flush))
return new Response(interleaved, { headers: { 'content-type': 'text/html' } })

The transformer drains one final time on close, so entries that settled after the last chunk still reach the client. A stream that ends inside markup gets no final batch, since no script can go there.

HydrationBoundary(props)

Hydration boundary for SSR: constructs a Root<Api> once on the client with the supplied DehydratedState (typically serialized into the HTML by root.dehydrate() on the server), then provides it to descendants.

Usage:

tsx
// server: render -> root.dehydrate() -> serialize into HTML. Query data
// is untrusted text: `serializeForScript` escapes it for the script, so a
// `</script>` inside it cannot end the tag.
import { serializeForScript } from '@kontsedal/olas-core'
const html = `<script>window.__OLAS_STATE__ = ${serializeForScript(root.dehydrate())}</script>`

// client entry: `hydrate` needs a query engine to land in.
<HydrationBoundary
  def={appController}
  options={{ deps, queries: queryEngine(), hydrate: window.__OLAS_STATE__ }}
>
  <App />
</HydrationBoundary>

The boundary **owns** the root: it is built during the first render, so the children can read hydrated data in that render. options is read **once** on mount — a new inline options={{...}} on a parent re-render is intentionally ignored (so the example above doesn't discard cache state every render). The root is recreated only when the def identity changes; to swap it on navigation, pass a different def (or re-key the component).

**Streaming.** The batches createStreamingHydrator wrote into the page before the boundary built its root go into that root's hydrate, so the hydrating render reads them and no fetch starts for them. Later batches arrive through the intake. A root built for a new def takes none of them: the stream described the first root's tree.

**Unmount, and a hidden <Activity>.** React cleans up the boundary's effects on an unmount and when an <Activity> above hides it, and gives no way to tell the two apart. So a cleanup suspends the root, and disposes it a minute later unless the boundary's effects run again first, as they do when the <Activity> shows. A hide shorter than that keeps the root and its state; after a longer one the boundary builds a fresh root from options.

**A render that never commits.** A child that suspends or throws before the boundary's first commit makes React discard the render, and the root with it. A retry of the same element reuses that root. A root no commit claims is disposed about ten seconds after its work goes idle, or after a minute if it never goes idle. A <Suspense> above that later hides the boundary's content does not dispose the root: a hide is not an unmount. A parent below an outer<Suspense> re-creates the element on every retry; that retry reuses an unclaimed root built from the same def, the same hydrate object anddeps with the same members. When those options change between attempts, the retry builds a new root and refetches, and a development build warns once. A <Suspense> inside HydrationBoundary, around the part that suspends, avoids all of this: the boundary commits first.

**SSR contract.** During server rendering, callers construct a per-request root and pass it to <OlasProvider root={...} />, then dispose it after the response. The HydrationBoundary shape is the *client-side* mirror — it accepts a controller def + the dehydrated state and produces a root that matches what the server rendered. Rendered on the server, it builds a root that nothing disposes, because a server render runs no effects; a development build warns once when that happens.

installStreamingIntake(root)

Connect a live root to the streamed hydration entries. The root first receives every batch that has already arrived, then each new batch as the stream delivers it. Several roots can be installed at once. Returns the uninstall function.

For a root built outside <HydrationBoundary>, such as one a custom provider shell creates. The boundary connects its own root, and folds the batches already on the page into the root as it builds it.

OlasProvider(props)

Provides an Olas root to descendant components. The root is created once (typically in main.tsx) and passed through here so React doesn't own the controller's lifetime — the adapter only reads. See spec §16.

SuspendOnUnmount(props)

Wrap a sub-tree so unmount calls controller.suspend() and re-mount calls controller.resume() instead of disposing. The React tree is still unmounted (this is NOT Vue-style <KeepAlive> DOM preservation — DOM, scroll, focus, input state are NOT retained); only the *controller* stays alive and its effects pause. Use it for routed sub-trees whose computed state is expensive to rebuild but whose DOM you're happy to re-render. See spec §20.10.

**Cross-fade safe.** Multiple wrappers around the same controller are refcounted: resume() fires only when the FIRST mounts and suspend() only when the LAST unmounts. So during a cross-fade — the entering screen mounts while the exiting one is still mounted — the controller stays resumed regardless of the order React runs the effects, and the exiting screen's unmount can't suspend a controller the entering screen still uses (T4.6). suspend() should still be idempotent for safety.

**Shares its bookkeeping with useSuspendOnHidden.** A first mount while a hidden tab holds the controller suspended leaves it suspended until the tab shows. Once the last wrapper unmounts, the tab showing again does not resume it.

useField(field)

Subscribe to all signals on a Field<T> with a single useSyncExternalStore call. Returns the plain values plus the action methods so a binding to an<input> is one destructure. See spec §20.10.

useFieldInput(field, options)

JSX-ready spread for binding a Field<T> to a native <input> /<textarea> / <select>. Subscribes to the field's value, errors, and touched signals; returns props you can spread directly:

tsx
<input {...useFieldInput(form.fields.title)} />

For non-string fields, pass a transform:

tsx
<input
  type="number"
  {...useFieldInput(form.fields.age, {
    transform: { parse: Number, format: String },
  })}
/>

The returned onChange reads e.target.value and writes through the transform; onBlur calls markTouched() so validateOn: 'blur' modes activate without any extra wiring. aria-invalid is set when the field has been touched AND has errors (avoid the "errors on every keystroke" UX even when validators run on change). For the error *message*, render your own element and point the input at it with aria-describedby={errId} — the hook does NOT emit aria-errormessage because per ARIA that attribute takes an element-ID reference, not the error text.

useFieldInput(field, options)

JSX-ready spread for a Field<T> whose value is not a string. transform converts between the field's value and the input's string: format forvalue, parse for each change.

tsx
<input type="number" {...useFieldInput(age, { transform: { parse: Number, format: String } })} />

useInfiniteQuery(subscription)

Subscribe a component to an infinite query subscription a controller made with createQuery(ctx, infiniteQuery, …). One subscription covers the pages, the flattened items and the paging flags, and it is fine-grained the way useQuery is: a list that renders flat and hasNextPage does not re-render while isFetchingPreviousPage flips.

tsx
const { flat, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery(api.feed)

{ suspense: true } suspends until the first page lands, with useQuery's rules.

useInfiniteQuery(subscription, options)

Subscribe a component to an infinite query subscription with Suspense. The hook suspends until the first page lands, with useQuery's rules, and thendata is TPage[].

useMutation(mutation, callbacks)

Subscribe to all signals on a Mutation<V, R> with a single useSyncExternalStore call. Returns the observable values plus two triggers:

  • mutate(vars) for an event handler. It returns nothing, and a failure surfaces on error / status and through onError. - run(vars) when the caller needs the result. It returns the run's promise, which rejects on failure.

The hook is a subscription layer: concurrency (latest-wins, serial, …) is configured on the mutation in the controller.

useQuery(subscription)

Subscribe a component to an AsyncState<T>: a query subscription or a local cache. Returns the plain values plus the action functions. See spec §20.10.

**Fine-grained.** The component re-renders only when a field it read during render changes. const { data } = useQuery(sub) does not re-render when a background refetch flips isFetching. A field read later, in an event handler or an effect, returns its current value and is tracked from then on. Spreading the result reads, and so tracks, every field.

Pass { suspense: true } to opt into React 18/19 Suspense semantics:

  • Until the first load settles the hook **throws** subscription.firstValue() — caught by the nearest <Suspense> boundary. - When that first load fails, the hook **throws** subscription.error — caught by the nearest <ErrorBoundary> (React itself doesn't ship one; use react-error-boundary or your own). A background-refetch failure that keeps the last-good data does NOT throw. - On success the hook returns synchronously and data is typed T. A load that settled on undefined (a select over an optional field, a fetcher that resolves nothing) returns it as it is. - A disabled (enabled: () => false) query suspends until it is enabled and loads, because firstValue() waits for the subscription to attach. That is what a dependent query wants. A query that is never enabled keeps the fallback up, and development builds warn once when that starts.

Refetches AFTER a first success do NOT re-suspend — only the initial load throws. reset() does NOT re-suspend either: it clears error/status but keeps data, so status returns to 'success' (spec §5). There is no built-in way to force re-suspension short of a fresh subscription.

useQuery(subscription, options)

Subscribe a component to an AsyncState<T> with Suspense. The hook throwssubscription.firstValue() until the first load settles, for the nearest<Suspense>, and throws the error of a first load that fails. On successdata is T. Refetches after the first success do not re-suspend.

useRoot()

Resolve the root's public api from <OlasProvider>. Throws if called outside a provider — this catches the common "I forgot to wrap" mistake at the first hook call. See spec §20.10.

The return type is the root registered through Register. Without a registration it is unknown, and useRoot<Api>() names it per call, as an unchecked cast. For several roots, createOlasContext<Api>() gives each its own provider and a typed useRoot.

useSuspendOnHidden(controller)

Auto-suspend a controller when document.visibilityState === 'hidden', and resume on visible. See spec §20.10.

The effect undoes itself on cleanup: if it is the reason the controller is suspended, it resumes before it goes. Unmounting a hidden tab's subtree — or swapping the controller argument while hidden — would otherwise leave that controller suspended with nothing left listening for thevisibilitychange that was supposed to wake it.

It shares its bookkeeping with <SuspendOnUnmount>, so it resumes only a controller nothing else holds suspended. A controller whose last wrapper unmounted while the tab was hidden stays suspended when the tab shows, and when this hook's own component unmounts.

useSuspenseQuery(subscription)

Suspense-first variant of useQuery. data is always T (the hook suspends until the first success, after which refetches don't re-suspend). Errors throw to the nearest ErrorBoundary **only on the initial load, before any data lands** — a later background-refetch failure keeps the last-good data rendered (the error stays observable via a non-suspense useQuery).

Sugar over useQuery(sub, { suspense: true }); exists so call sites read as useSuspenseQuery(sub) without an options bag.

useValue(signal, options)

Subscribe to a single read-signal and return its current value. AnyReadSignal works: a signal, a computed, a Field, a Form or aFieldArray.

Built on useSyncExternalStore — concurrent-safe, no tearing. Use this when a component depends on one signal; for Field<T> and AsyncState<T>, prefer useField and useQuery which batch multiple subscribes into one render trigger.

Optional select projects the signal value into a derived slice; isEqual (default Object.is) controls when React re-renders. Combine to subscribe to a slice of an object-shaped signal without re-rendering on unrelated changes:

ts
const name = useValue(userSignal, { select: u => u.name })
const tags = useValue(postSignal, {
  select: p => p.tags,
  isEqual: (a, b) => a.length === b.length && a.every((x, i) => x === b[i]),
})

useValue(signal, options)

Subscribe to a single read-signal and return its current value: a signal, a computed, a Field, a Form or a FieldArray. The component re-renders when the value changes, as options.isEqual (defaultObject.is) decides. Built on useSyncExternalStore.

Interfaces ​

Interface

Description

Register

Register the app's root type once, and useRoot() returns its api with no type argument. Empty here: the app adds root through declaration merging.

ts
const root = createRoot(appController, { deps })

declare module '@kontsedal/olas-react' {
  interface Register {
    root: typeof root
  }
}

Variables ​

Variable

Description

OLAS_BOOTSTRAP_SCRIPT

Bootstrap script that primes the client's intake queue *before* React's hydration runs. Drop the string into renderToPipeableStream'sbootstrapScriptContent (or bootstrapScripts / a manual <script>):

ts
renderToPipeableStream(<App />, {
  bootstrapScriptContent: OLAS_BOOTSTRAP_SCRIPT,
  onShellReady() { ... },
})

The intake is a tiny "push-only array". Batches land here, and a<HydrationBoundary> folds the ones already there into the root it builds. Once it commits, later pushes go through the forwarder it installs.

STREAMING_GLOBAL

Global key under which the client-side bootstrap stashes the queue of incoming dehydrated entries. Exposed as a constant so consumers writing custom serialization can match it.

Type Aliases ​

Type Alias

Description

HydrationBoundaryProps

Props of <HydrationBoundary>.

MutateFn

useMutation's fire-and-forget trigger: run's arguments, no promise.

OlasContext

What createOlasContext<Api>() returns: a provider and useRoot typed to one root.

OlasProviderProps

Props of <OlasProvider>.

RegisteredApi

The api useRoot() returns by default: the registered root's, else unknown.

StreamingHydrator

Result of createStreamingHydrator(). The plugin field is what you register on the server's root; the flush() method pulls any server-resolved entries that haven't been flushed yet, formatted as a<script> tag.

**Where the tag goes matters.** React writes its stream in fixed-size chunks, so a chunk boundary can fall inside a tag, an attribute value or a text node. A <script> written there breaks the markup, the payload's quotes can close the attribute, and one inside an element React hydrates breaks hydration. Let createStreamingTransform place the tags: it writes one only where React's hydration never sees it. With Node'srenderToPipeableStream, render with renderToReadableStream instead (Node has Web Streams) and pipe through the transform, or writeflush() yourself only after the stream has ended.

tsx
const { plugin, flush, dispose } = createStreamingHydrator({ nonce })
const root = createRoot(appDef, { deps, queries: queryEngine(), plugins: [plugin] })
const stream = await renderToReadableStream(
  <OlasProvider root={root}>
    <App />
  </OlasProvider>,
  { bootstrapScriptContent: OLAS_BOOTSTRAP_SCRIPT, nonce },
)
return new Response(stream.pipeThrough(createStreamingTransform(flush)))
// Once the response has finished: root.dispose(), then dispose().

StreamingHydratorOptions

Options for createStreamingHydrator.

SuspendableController

What <SuspendOnUnmount> and useSuspendOnHidden pause: an object withsuspend() and resume(). The handle ctx.attach(...) returns fits, and so does a Root.

SuspendOnUnmountProps

Props of <SuspendOnUnmount>.

UseFieldInputOptions

Options for useFieldInput. A field whose value is not a string needs transform.

UseFieldInputResult

Props useFieldInput returns, ready to spread onto a native input.

UseFieldResult

What useField returns: the field's signals as plain values, plus its actions.

UseInfiniteQueryResult

What useInfiniteQuery returns: useQuery's fields, the paging state, and the paging actions. The component re-renders only when a field it reads changes.

UseMutationCallbacks

Callbacks useMutation runs after a run settles. They fire from the React layer, not the controller: put cache work on the mutation's own hooks. A run that was aborted (superseded, reset or disposed) fires none of them, as the mutation's own hooks don't.

UseMutationResult

What useMutation returns: the mutation's signals as plain values, plus its triggers.

UseQueryResult

What useQuery returns: every AsyncState signal read as a plain value, plus its actions. The component re-renders only when a field it reads changes.

UseSuspenseInfiniteQueryResult

What useInfiniteQuery(sub, { suspense: true }) returns.

UseSuspenseQueryResult

What useSuspenseQuery (and useQuery(sub, { suspense: true })) returns.

UseValueOptions

Options for useValue.

UseValueSelectOptions

Options for useValue with a projection: the hook returns select(value).

Released under the MIT License.