Loaders
Read data at the route boundary and keep requests out of rendering components.
Compatibility
Define the read once
A loader receives the URL, decoded path params, and a cancellation signal. Return the data the view needs. clientLoader runs in the browser; a server loader runs on the server when connected through the Vite integration or serverRoute. A plain manually registered function executes in the environment where that router runs.
// routes/tasks.$id/clientLoader.ts
import type { DataArgs } from "@kasperrt/smolter/react";
import { tasks } from "../../data/tasks";
export async function clientLoader({ params, signal }: DataArgs) {
return tasks.read(params.id, { signal });
}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) };
}Register and render the route
Manual registration associates a URL pattern with the module. File routing derives /tasks/:id from tasks.$id/route.ts. Export the handler and view from that module. The tasks service above is application code: its read method returns a typed task or an Error.
// routes/tasks.$id/route.ts
export { clientLoader } from "./clientLoader";
export { default } from "./view";
export { HydrateFallback } from "./HydrateFallback";
// router.ts
import { createDataRouter, defineRoute } from "@kasperrt/smolter/react";
import * as task from "./routes/tasks.$id/route";
export const router = createDataRouter([defineRoute("/tasks/:id", task)], {
browser: true,
});Consume the inferred result
useLoaderData<typeof loader>() derives the result from the handler’s return type. Use a type-only import. TypeScript cannot infer a handler from a nearby file; passing a typed route to useLoaderData is the argument-based alternative.
// routes/tasks.$id/view.tsx
import { useLoaderData } from "@kasperrt/smolter/react";
import type { clientLoader } from "./clientLoader";
export default function Task() {
const task = useLoaderData<typeof clientLoader>();
return <h1>{task?.title}</h1>;
}Query strings belong to request.url
params holds path captures. Read filters with new URL(request.url).searchParams. A GET Form navigates to that query URL and runs the loader.
const url = new URL(request.url);
const q = url.searchParams.get("q") ?? "";
const page = Number(url.searchParams.get("page") ?? "1");
return tasks.search({ q, page, signal });Initial loading and later revalidation
Routes renders HydrateFallback while the initial data is unavailable. A successful undefined result still counts as loaded. Later revalidation can keep the current view visible with its existing data. shouldRevalidate controls subsequent reads; it cannot eliminate the first necessary load.
Server loader with a client wrapper
Keep loader and clientLoader as separate named exports. During SSR the server loader supplies the HTML data. Later browser loads use clientLoader, which may call serverLoader. A hydrated loaded snapshot avoids an automatic duplicate initial read. A client-only route with no server data loads in the browser.
// clientLoader.ts
import type { ClientLoaderArgs } from "@kasperrt/smolter/react";
import type { loader } from "./loader";
export async function clientLoader({
serverLoader,
}: ClientLoaderArgs<typeof loader>) {
const data = await serverLoader();
return { ...data, compact: localStorage.getItem("compact") === "true" };
}
// route.ts
export { loader } from "./loader";
export { clientLoader } from "./clientLoader";
export { default } from "./view";Cancellation and parallel requests
Pass the provided signal into each network request. A newer navigation aborts the previous navigation request and prevents stale results from winning. Independent fetchers have their own cancellation channels. Use Promise.all for independent reads within a loader, and handle returned Error values before rendering the combined data.
JSON across the server boundary
Server transport and hydration serialize data. Prefer plain records, arrays, strings, numbers, booleans, and null. Dates become strings; functions and class instances do not retain their runtime behavior. A TypeScript return annotation cannot make a value survive serialization unchanged.
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.