Async Values | Koze | Primitives Docs

Async Values

Handle async data with loading, error, and success states

Async Values

Koze lets you write async data-dependent templates without blocking SSR. Call an async function without await and the binding becomes an AsyncValue<T> — an object exposing pending, error, and success state alongside the eventual value. The initial page responds immediately with the pending branch of your template; when the promise settles, the resolved branch streams in and the browser swaps it into place.

Two patterns

Pattern Returns Use case
const x = fn() AsyncValue<T> Need loading / error states; SSR stays non-blocking.
const x = await fn() T Just need the value — SSR blocks until it resolves.

AsyncValue API

When you call an async function without await, the binding exposes three observable flags:

interface AsyncValue<T> extends T {
  pending: boolean;      // true while loading
  error: string | null;  // error message if rejected, null otherwise
  success: boolean;      // true when resolved
}

The AsyncValue is the value — properties and iteration work directly. No .data wrapper.

Basic usage

<script>
  import { getTodos } from '$server/todos';

  const todos = getTodos();   // no await → AsyncValue<Todo[]>
</script>

if (todos.pending) {
  <div class="skeleton">Loading…</div>
} else if (todos.error) {
  <p class="error">Failed: {todos.error}</p>
} else if (todos.success) {
  for (const todo of todos) {
    <TodoItem todo={todo} />
  }
}

That's the whole thing. No <Boundary> tag, no lifecycle hook, no custom component — plain template control flow. The framework spots the .pending access on todos, compiles the if/else-if/else chain into a streaming boundary automatically, and sends the initial HTML (with the pending branch rendered) while the real call runs in the background.

Note: Koze does not use Svelte's {#await} block syntax. Async state is modeled as normal JavaScript data: call without await, branch on .pending, .success, or .error, and let the compiler turn that if-chain into the boundary.

Note: .pending is the signal. The framework detects async bindings by looking for X.pending in the template. Any top-level <script> binding referenced that way becomes an AsyncValue; the surrounding if-chain becomes a stream boundary. Server-action state also exposes .pending, but stateful augment() aliases are action state, not streamed AsyncValues. See Actions for form state.

Region inference

The framework streams the smallest contiguous if/else-if/else chain whose head condition references an async binding. Everything else on the page renders synchronously on the first response.

<script>
  const issue = await getIssue(id);  // awaited — blocks SSR
  const ai = analyze(issue.key);     // non-awaited — streamed
</script>

<h1>{issue.title}</h1>         <!-- rendered on first response -->
<p>{issue.description}</p>     <!-- rendered on first response -->

if (ai.pending) {              <!-- this chain is the boundary -->
  <Skeleton />
} else if (ai.error) {
  <p>{ai.error}</p>
} else if (ai.success) {
  <p>{ai.summary}</p>
}

<footer>©{year}</footer>       <!-- rendered on first response -->

Only the if-chain is a boundary — the heading, the description, and the footer are in the initial HTML. When analyze(...) resolves, the framework streams the chain's resolved markup and swaps it in place of the <Skeleton />.

Multiple async values in one chain

When a chain's condition references more than one async binding, the framework combines their promises with Promise.all and re-renders the chain once all bindings have settled:

<script>
  const user = getUser();
  const notifications = getNotifications();
</script>

if (user.pending || notifications.pending) {
  <DashboardSkeleton />
} else if (user.error || notifications.error) {
  <p>Failed to load: {user.error || notifications.error}</p>
} else if (user.success && notifications.success) {
  <Dashboard user={user} notifications={notifications} />
}

Both user and notifications receive the same AsyncValue state (pending → success or error). If either promise rejects, the chain takes the error branch; the error message is whichever rejection won the race.

Combining into one Promise

If you want several async sources to land atomically as a single value, combine them with Promise.all at the binding site:

<script>
  import { suggestCategory, listPaths } from '$server/taxonomy';

  const panel = Promise.all([
    suggestCategory(key),
    listPaths(),
  ]).then(([suggestion, paths]) => ({ suggestion, paths }));
</script>

if (panel.pending) {
  <Skeleton />
} else if (panel.error) {
  <p>{panel.error}</p>
} else if (panel.success) {
  <h3>{panel.suggestion.bestPath}</h3>
  <small>{panel.paths.length} paths considered</small>
}

This shape gives you a single AsyncValue whose success branch can destructure the combined result.

Blocking (await)

When a page doesn't make sense without the data — title, authentication, the matching record — use await:

<script>
  const todos = await getTodos(); // blocks SSR until resolved
</script>

for (const todo of todos) {
  <TodoItem todo={todo} />
}

A good rule of thumb: await what the user would leave the page over if it were missing. Non-awaited AsyncValues are for the rest.

Browser RPC and invalidation

When a $server/* function is called from the browser, the generated stub returns the same thenable AsyncValue<T> shape:

<script>
  import { refreshTodos } from '$server/todos';

  async function refresh() {
    const result = refreshTodos();

    if (result.pending) {
      console.log('Refreshing...');
    }

    await result;
  }
</script>

<button>Refresh</button>

The browser call goes through Koze's Capn Web channel endpoint. On success or error, the runtime dispatches koze:invalidate-reads so hydrated reactive effects can re-run against the latest server state.

Success guard

Use else if (x.success) in place of a naked else when the template body needs data from the resolved value. The pending AsyncValue is an empty object — accessing .data or iterating it during pending renders nothing, which is rarely what you want.

if (todos.pending) {
  <Skeleton />
} else if (todos.error) {
  <p>Failed: {todos.error}</p>
} else if (todos.success) {
  for (const todo of todos) {
    <TodoItem todo={todo} />
  }
}

Type safety

AsyncValue<T> extends T, so type inference flows through:

interface Todo {
  id: string;
  title: string;
  done: boolean;
}

// getTodos returns Promise<Todo[]>; bound without await: AsyncValue<Todo[]>
const todos = getTodos();

if (todos.success) {
  for (const todo of todos) {
    console.log(todo.title); // string
    console.log(todo.done);  // boolean
  }
}

Live workflow status

For Cloudflare Workflow instance status — which changes over time — use workflowStatus from koze:workflow. It returns the same AsyncValue shape and, when { poll } is passed, the framework re-renders the whole route on each tick.

<script>
  import { params } from 'koze:request';
  import { workflowStatus } from 'koze:workflow';

  const status = await workflowStatus('migration', params.id, { poll: '2s' });
</script>

Runtime helpers

Import from koze for AsyncValue construction and type guarding outside of streamed boundaries:

import {
  createPendingValue,
  createSuccessValue,
  createErrorValue,
  isAsyncValue,
  type AsyncValue,
} from '@kuratchi/koze';
Helper Purpose
createPendingValue<T>() Returns an empty AsyncValue with pending=true.
createSuccessValue<T>(v) Wraps a resolved value with success=true.
createErrorValue<T>(msg) Returns an AsyncValue with error=msg.
isAsyncValue(v) Type guard for inspecting unknown values.