Loaders and actions
A route owns its reads and writes. Components render their results.
Compatibility
Browser-only data
clientLoader and clientAction run in the browser. Use these for local storage or requests to your own API. They do not create backend endpoints.
// loader.ts
import type { DataArgs } from "@kasperrt/smolter/react";
export async function clientLoader({ request, signal }: DataArgs) {
const query = new URL(request.url).searchParams.get("q") ?? "";
const response = await fetch(`/api/tasks?q=${encodeURIComponent(query)}`, { signal });
return response.json();
}
// route.tsx
export { clientLoader } from "./loader";
export { clientAction } from "./action";
export { default } from "./view";Deferred client-loader data
From 0.4.6, clientLoader waits only for its top-level result. Promises nested in records or arrays retain their identity: navigation, revalidation and fetcher loads can finish while optional data remains pending. Await required work before returning. Read optional values with Preact Await or React 19 use(promise) inside Suspense; React needs an error boundary for rejection. React 18 has no use hook: keep awaiting required data or use an existing Suspense-compatible reader. Do not render a promise directly.
import type { DataArgs } from "@kasperrt/smolter/preact";
async function readStats(signal: AbortSignal) {
const response = await fetch("/api/stats", { signal });
if (!response.ok) throw new Error("Statistics unavailable");
return response.json();
}
export function clientLoader({ signal }: DataArgs) {
return { title: "Rooms", stats: readStats(signal) };
}Server data
loader and action execute on the server when using the generated transport or serverRoute. handleRequest must be installed on your production server. Exporting a server handler in a plain browser route table does not move that code to a backend automatically.
// action.ts
import type { DataArgs } from "@kasperrt/smolter/react";
export async function action({ request, params }: DataArgs) {
const body = await request.formData();
return updateTask(params.id, String(body.get("title")));
}Client call-through
With serverRoute or the Vite-generated browser route table, a client handler can call its paired server handler. Read client action form data freely: the transport preserves a clone for serverAction. SSR runs server handlers and skips client handlers.
import type { ClientLoaderArgs } from "@kasperrt/smolter/react";
export async function clientLoader({ serverLoader }: ClientLoaderArgs) {
const serverData = await serverLoader();
return { serverData, online: navigator.onLine };
}Pending, errors, cancellation
A newer request aborts the older request on the same channel. Pass signal to fetch and other abort-aware work. Returned or rejected errors become route error state. HydrateFallback renders before the first successful loader; ErrorBoundary receives an error prop. An error does not undo writes already committed by a server.
Deferred ownership and failures
Pass signal to optional fetches so a newer navigation, revalidation or fetcher disposal can abort them. Consumed prefetch data uses the consuming navigation’s cancellation signal. Smolter observes nested rejections even if their page is abandoned, without replacing the promises or suppressing errors from Await/use. Settlements do not rerun loaders, change the current route or extend router pending state. Client-created promises have no Smolter timeout: deferredTimeout only bounds initial SSR hydration data. Add a request deadline in application code when needed.