Cloud|

Browser clients and mutations

Call typed application APIs and handle user-initiated writes consistently.

4 min read Updated 2026-10-14 #browser#api#mutations

Call application JSON APIs through a typed Hono client.

Wrap user-initiated async work in mutation.create() so loading, errors, aborts, retries, and stale results follow one contract.

Create a typed client

Export the Hono route type from the application server:

ts
export type InventoryApi = typeof inventoryRoutes;

Create the browser client in a browser-safe module:

ts
import { api } from "@k2b/cloud/browser";
import type { InventoryApi } from "../api";

export const inventoryApi = api.create<InventoryApi>({
  baseUrl: "/api/inventory",
});

The client infers route parameters and request payloads. Check response.ok before reading success data.

On a page of the mobile app, the client also keeps the app session alive. When a request answers 401, the client renews the app session through Core and, once the phone has a valid session, sends the request once more. A 401 means that the server did not apply the request: the authentication middleware answers it before any route handler runs, and a route that answers 401 itself must do so before it changes anything, as described in Return expected failures. The repeat therefore applies a change at most once. When the phone is no longer paired, the app opens its pairing view instead. On the web, a 401 is returned as it is.

Do not use raw fetch() for an application JSON API when its typed route is available.

Run a mutation

tsx
import { mutation } from "@k2b/stdlib/solid";
import { toast } from "@k2b/ui";

const archive = mutation.create<void, { itemId: string }>({
  mutation: async ({ itemId }, { abortSignal }) => {
    const response = await inventoryApi.items[":id"].$delete(
      { param: { id: itemId } },
      { init: { signal: abortSignal } },
    );
    if (!response.ok) throw new Error("Item could not be archived.");
  },
  onSuccess: () => toast.success("Item archived"),
  onError: (error) => toast.error(error.message),
});

mutate(vars) starts the operation. loading(), error(), and data() are reactive accessors.

abort() cancels the active operation. An aborted fetch calls onAbort, not onError.

retry() repeats the previous variables and context. It does not run onBefore again.

When a newer mutation starts, a late result from an older mutation is ignored.

Capture the complete retryable intent before the request starts. Mutation variables or one-time context must include selected resources, destinations, the request payload, idempotency keys, and correlation IDs. A retry must not read a new choice from mutable UI state or reuse an idempotency key with a different payload.

onSuccess, onError, onAbort, and onFinally are synchronous hooks. Their return values are not awaited. Put work that defines the command outcome in the mutation function. Track post-write reconciliation separately.

error() and onError report only a failed mutation function. onSuccess runs after data() and loading() are updated; an error thrown there, or by a component that renders the result, does not count as a failed write. A render error reaches the island's boundary (When an island fails), and mutate() and retry() reject with the remaining ones. Await mutate() in try/catch only when the caller must react to such an error.

Add optimistic state carefully

Every action answers at once, with an optimistic update or a pending state on the acted element. Show mutation state decides which one an action gets and when it offers Undo. This section covers the mechanics.

onBefore may return context used by success, error, abort, and finally hooks. Use it to capture the previous UI state, such as a removed row and its position, before the optimistic change.

Restore that state on error and abort, in the same place, so the item returns where the person left it. Restore it only if no later action on the same item has taken over that state, so an earlier failure never rolls back a newer choice; then reconcile with the server. Do not optimistically grant permission, expose new data, or pretend an irreversible action completed.

For an action that offers Undo, Undo either cancels a request that has not been sent yet or sends a request that reverses the change on the server. Cancelling an unsent request never makes a hard delete eligible for Undo. Undo restores the captured state at once, so keep that context until the Undo notice closes. Treat Undo as its own mutation: reconcile with the server after it, and if the reversal is refused or fails, show the state the server kept and an error with the reason.

The server remains authoritative. Reconcile the returned resource or reload the affected server-backed view after success. If that refresh fails, keep the optimistic result; the write already succeeded.

Separate query and mutation state

Use a mutation for a user-initiated write or command. Do not use a mutation to load a server-backed result set.

Start result sets with URL-addressed SSR data and keep them current with an owner-local query. See Server-backed state.

Do not turn the mutation result into a client-side cache of the application's domain model.

See Forms, prompts, and feedback for presenting the operation.

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.