Data
A page reads data by calling a handler. A handler is a typed function you write in server/ and export from server/index.ts; the page imports it from #server and calls it. In the browser the call is a request, on the server it is a function call in the same process.
| The page needs | Use |
|---|---|
| data it shows | useAsyncData(fn, input) |
a status, a redirect or the error page decided before the first byte, or parent() across a chain of routes | x.load.ts calling handlers |
the answer of another service, or of a handler that has a route | useFetch(url) |
| data only the browser has | useAsyncData(key, fn, { server: false }) |
app/ holds no server code. Everything that reads a database, a secret or a file is a handler.
A handler for a page
This file defines three handlers. Each one has to be exported from server/index.ts to be reachable from a page, as Server shows.
import { createError, defineHandler, refresh } from "silgi/server";
import { comments, posts } from "./utils/posts.ts";
import { idInput, schema } from "./utils/schema.ts";
export const listPosts = defineHandler({
method: "GET",
handler: () => [...posts.values()],
});
export const getPost = defineHandler({
method: "GET",
route: "/api/posts/:id",
input: idInput,
handler({ id }) {
const post = posts.get(id);
if (!post) throw createError({ status: 404, message: "Post not found" });
return post;
},
});
export const getComments = defineHandler({
method: "GET",
route: "/api/posts/:id/comments",
input: idInput,
handler: ({ id }) => comments.get(id) ?? [],
});
const rename = schema<{ id: string; title: string }>(
{ type: "object", properties: { id: { type: "string" }, title: { type: "string", minLength: 1 } }, required: ["id", "title"] },
(value) => {
const { id, title } = value as { id?: unknown; title?: unknown };
if (typeof id !== "string" || typeof title !== "string" || !title) return { issues: [{ message: "A title is required", path: ["title"] }] };
return { value: { id, title } };
},
);
export const renamePost = defineHandler({
input: rename,
async handler({ id, title }, { event }) {
const post = posts.get(id);
if (!post) throw createError({ status: 404, message: "Post not found" });
posts.set(id, { ...post, title });
await refresh(event, getPost, { id });
return { id, title };
},
});
useAsyncData with a handler
useAsyncData(fn, input) calls the handler while the page renders and returns data, error, status, pending, refresh(), execute() and clear(). The key is made from the handler and the input, so you never write one, and two components that make the same call share it. input may be a function that returns the input; when its result changes, the component moves to the new call.
import { useAsyncData, useRoute } from "@silgi/react";
import { getPost } from "#server";
export default function Post() {
const { id } = useRoute().params;
const { data, error, status } = useAsyncData(getPost, () => ({ id: id as string }));
if (error) return <p role="alert">{error.message}</p>;
if (!data) return <p>{status === "pending" ? "Loading" : ""}</p>;
return (
<article>
<h1>{data.title}</h1>
<p>{data.body}</p>
</article>
);
}
On the server the call runs in process, with the request's cookies and headers. The answer goes into the page payload, so the browser does not ask again while it hydrates. A handler with no input is called as useAsyncData(listPosts).
import { Link, useAsyncData } from "@silgi/react";
import { listPosts } from "#server";
export default function Posts() {
const { data: posts = [] } = useAsyncData(listPosts);
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
);
}
Every library renders the data into the first HTML:
| Library | Server rendering waits for the data? |
|---|---|
| React | yes: the component suspends while the call runs |
| Vue | yes |
| Solid | yes: the render is repeated until the data has arrived |
| Preact | yes: the render is repeated until the data has arrived |
| Svelte | yes: the render is repeated until the data has arrived |
| html | yes: the render is repeated until the data has arrived |
A component behind Suspense in React can no longer change the status of the response: the shell has been sent by then. When a page has to decide its status, use load.
x.load.ts
A load is the load export of a file beside the page with the same name and a .load.ts ending: dashboard.load.ts loads the data for dashboard.tsx. A layout's is app/layouts/<name>.load.ts. It runs on the server for the first render and in the browser for every navigation, before the page chunk has arrived, and its value is what useData() returns. It calls handlers; it holds no server code of its own.
import type { Load } from "silgi/app";
import { getAccount } from "#server";
export const load = (async () => ({ account: await getAccount() })) satisfies Load;
Write satisfies Load, not : Load. An annotation fixes the return type to unknown, and the route's data with it.
silgi typegen writes a route types file for every page, which the page and its .load.ts import from ./+types/<page>:
// app/pages/blog/[slug].load.ts
import type { Route } from "./+types/[slug]";
export const load = (async ({ params }) => getPost({ id: params.slug })) satisfies Route.Load;
Route.Load types ctx.params ({ slug: string }; a catch-all is a string[], an optional segment an optional key), ctx.search and ctx.parent() for this route. The namespace also has Name (so useData<Route.Name>() needs no route name written by hand), Params, LoadContext, LoadData, ParentData, Matches and Errors. The files live in .silgi/types/pages/; .silgi/tsconfig.json sets rootDirs so ./+types/… resolves beside the page. To add directories, set typescript: { tsConfig: { rootDirs: [...] } } in the config: typegen appends them to its own list (paths relative to .silgi/).
The context has params, query, search, url, route, event, fetch, parent(), setHeaders(headers), runtimeConfig, signal, depends(...keys), untrack(fn) and prime(...).
eventis the request on the server andundefinedin the browser.fetchis$fetchbound to the request on the server, with its cookies andauthorizationheader, and$fetchin the browser. A call it makes is a dependency thatrouter.refresh(key)can re-run.setHeaderssets headers on the document and does nothing in the browser.runtimeConfigis the client runtime config on both sides.depends(...keys)declares keys;router.refresh(key)runs thisloadagain for them.prime(fn, input, value)seeds theuseAsyncData(fn, input)entry, so the component reads it without a call.- A handler a
loadcalls on the server shares the document's request. ItssetResponseStatus, its cookies and a throwncreateErrororredirect()become the document's status, cookies and redirect, before the first byte. - A
redirect(to)returned or thrown goes toto. A target on another origin needsredirect(to, { external: true }). Any other throw shows the error page; see Errors.
What a load returns must be serializable. Besides JSON values, Date, RegExp, URL, BigInt, Map, Set, typed arrays, errors, and repeated or circular references survive. Functions and class instances do not; the request fails. The message names the path, such as payload.data["posts-id"].post.save. It is in the server log, and on the page only in development; in production the page shows a 500 Internal Server Error. On a static site silgi generate writes the data at build time and the browser never runs the load.
A chain of loads costs round trips
parent() resolves to the data of the nearest parent route that has a load, or undefined. It is the only ordering between loads: the load files of the matched routes start together, and a load that awaits parent() waits for its parent to finish first.
import type { Load } from "silgi/app";
import { listOrders } from "#server";
export const load = (async ({ parent }) => {
const { account } = await parent<{ account: { name: string } }>();
return { orders: await listOrders({ owner: account.name }) };
}) satisfies Load;
That wait has a price. When the parent's data comes from a handler, the parent's call is a round trip, and the child's call starts only after it ends. A chain of N routes that each wait on parent() makes N sequential round trips in the browser. Independent load files are not slowed: they run in parallel, N requests in one round-trip time.
When a chain needs the same data, compose it in one handler and call that handler from one load: one request, whatever the depth.
import type { Load } from "silgi/app";
import { getDashboard } from "#server";
export const load = (async () => ({ summary: await getDashboard() })) satisfies Load;
getDashboard in server/dashboard.ts returns the account and its orders from one function. Use parent() for what only the parent knows, and a composed handler for the rest.
A load does not guard anything
A load runs in the browser, and a user can skip it. A redirect() from it only steers a navigation. Authorization lives in the handler, or in the middleware the handler lists. A page that must be protected before it renders is a case for a request middleware on its path; see Sessions and security.
Layout loads
A layout has its own load: app/layouts/admin.load.ts beside app/layouts/admin.tsx. It starts together with the loads of the page, and ctx.route is the matched page. Navigating between two pages with the same layout keeps its data while the params and the query (or its searchKeys) stay the same; router.refresh() runs it again.
import type { Load } from "silgi/app";
import { getAccount } from "#server";
export const load = (async () => ({ account: await getAccount() })) satisfies Load;
The layout reads its data with useData().
import type { ReactNode } from "react";
import { useData } from "@silgi/react";
export default function Admin({ children }: { children?: ReactNode }) {
const { account } = useData<{ account: { name: string } }>();
return (
<div>
<nav>Signed in as {account.name}</nav>
{children}
</div>
);
}
A page below it never gets this data from its own useData(). The load of the first page with no parent load above it gets it from parent(). A layout load may export searchKeys, like a page's.
Search keys
A load runs again on a navigation that changes its inputs. By default every key of the query counts. Export searchKeys, an array literal of string literals, to make only those keys count. The ctx.query, ctx.search and ctx.url.search of that load hold only those keys, and a navigation that changes ?tab=a to ?tab=b leaves its data alone.
import type { Load } from "silgi/app";
import { listPosts } from "#server";
export const searchKeys = ["page"];
export const serialization = "auto";
export const load = (async ({ depends, search }) => {
depends("posts:list");
return { page: Number(search.page ?? 1), posts: await listPosts() };
}) satisfies Load;
Anything but an array of string literals fails the build: app/pages/feed.load.ts: searchKeys must be an array of string literals. A page's search schema (Routing) validates the whole query, so a key the schema requires has to be in searchKeys of the load that reads it.
Seeding useAsyncData from a load
ctx.prime(fn, input, value) writes the entry that useAsyncData(fn, input) would make. The component reads it on the server, during hydration and after a navigation, and the handler does not run a second time. ctx.prime(key, value) does the same for a keyed entry. When two loads prime one key, the innermost route wins, and silgi dev logs both names. The value has to be serializable.
import type { Load } from "silgi/app";
import { getPost } from "#server";
export const load = (async ({ params, prime }) => {
const id = params.id as string;
prime(getPost, { id }, await getPost({ id }));
return {};
}) satisfies Load;
What goes into the payload
The data a load returns is written into the page, so the browser does not run the load again while it hydrates. Export serialization from the load file to change that:
serialization | Effect |
|---|---|
"always" (the default) | the data is in the page |
"auto" | the data is in the page when its serialized text has at most 8192 characters |
"never" | the data is not in the page; the browser runs the load before it hydrates |
The server render has the data either way, so the HTML is the same. Any other value fails the request: serialization of route "feed" must be "always", "never" or "auto", got "sometimes". A meta.ssr: false page and a serialization: "never" page are still written in full into the payload file of a static build.
Types that JSON cannot hold travel as described above. For your own class, app/transport.ts exports transport = { Money: { encode, decode } } satisfies Transport, and an app plugin can register one with definePayloadReducer(app, name, reduce) and definePayloadReviver(app, name, revive). The first covers the page, the payload file, and the inputs and answers of handlers; a plugin's type reaches the page payload only.
In app/transport.ts:
import type { Transport } from "silgi/app";
export class Money {
constructor(readonly cents: number, readonly currency: string) {}
}
export const transport = {
Money: {
encode: (value) => value instanceof Money && [value.cents, value.currency],
decode: ([cents, currency]) => new Money(cents, currency),
},
} satisfies Transport;
In app/plugins/tag.ts:
import { definePayloadReducer, definePayloadReviver, definePlugin } from "silgi/app";
export class Tag {
constructor(readonly name: string) {}
}
export default definePlugin((app) => {
definePayloadReducer(app, "Tag", (value) => value instanceof Tag && value.name);
definePayloadReviver(app, "Tag", (name: string) => new Tag(name));
});
encode returns false for a value that is not its type. A type that is named twice fails with payload type "Money" is already registered. The rest of the table of errors is in the API reference.
Reading the data
useData() returns the load data of the route it is called in; a route with no load gets null. A layout without a load and the root component get null too, and a parent route never sees its child's data; useRouteData reads another route's. In React, Preact and html it is the value, in Vue a ComputedRef, in Solid an accessor, in Svelte an object with value. The data is read-only in every library; to change it on the page, copy it into local state first.
import { Outlet, useData } from "@silgi/react";
export default function Dashboard() {
const { account } = useData<{ account: { name: string } }>();
return (
<section>
<h1>Hello, {account.name}</h1>
<Outlet />
</section>
);
}
Refreshing
useRouter().refresh() runs the load files of the page again and refreshes every live useAsyncData entry: no history entry, no scroll, state kept. refresh(key) re-runs the load files that declared the key with depends or fetched it, and refreshes the entries of that key; refresh(fn, input) refreshes one handler's entry. A page may export shouldRefresh(args) to keep some data: it gets { currentUrl, nextUrl, defaultShouldRefresh } and answers true or false. A handler that changes what the page shows can send the next read with its own answer: see Fresh data in one round trip.
Refreshing by key
A load names the data it depends on. ctx.depends("posts:list") declares a key, any string, and every ctx.fetch call adds its URL as a key: an app path as written with its query applied and without app.baseURL, any other URL as its href. ctx.untrack(fn) runs fn and records nothing that fn calls before its first await. Handler calls are not recorded; name them with depends.
import type { Load } from "silgi/app";
import { listPosts } from "#server";
export const searchKeys = ["page"];
export const serialization = "auto";
export const load = (async ({ depends, search }) => {
depends("posts:list");
return { page: Number(search.page ?? 1), posts: await listPosts() };
}) satisfies Load;
import { useData, useRouter } from "@silgi/react";
import type { PageMeta } from "silgi/app";
export const meta = { staleTime: 30_000 } satisfies PageMeta;
export default function Feed() {
const { page, posts } = useData<{ page: number; posts: Array<{ id: string; title: string }> }>();
const router = useRouter();
return (
<main>
<h1>Page {page}</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button type="button" onClick={() => router.refresh("posts:list")}>
Reload
</button>
</main>
);
}
router.refresh("posts:list") (or a list of keys) runs again only the load files on screen that declared one of the keys, layouts included, and refreshes the useAsyncData entries of that key. A key without ? also matches that URL with any query: refresh("/api/posts") runs a load that fetched /api/posts?page=2, but not one that fetched /api/posts/1. A key that nothing declared does nothing. Calls in the same tick share one run, and a call during a navigation waits for it. There is no other name for this: invalidate is refresh(key).
The client data cache
The browser keeps the data of the load files it ran, per route, params and search (only the searchKeys when the load declares them). It is off until you give a page a time: with the defaults every navigation runs its load files again.
| Setting | Default | Meaning |
|---|---|---|
app.data.staleTime or meta.staleTime | 0 | milliseconds an entry stays fresh. A fresh entry is shown without running its load; the innermost page's meta wins over the config |
app.data.gcTime or meta.gcTime | 300000 | milliseconds after the page stopped showing an entry before it is deleted |
app.data.staleReload | "blocking" | "background" shows a stale entry at once and runs the load after the navigation commits |
app.data.refreshOnFocus | false | a number of milliseconds: refresh() when the tab becomes visible, at most once in that time |
app.data.refreshOnReconnect | false | true calls refresh() on the browser's online event |
Back and forward show the data the entry showed, under the same rule. The cache holds 100 entries. A fresh entry skips the load, and with it any redirect or check inside it; the handlers it would have called still check every call. Every entry is dropped by router.refresh(), by a successful useAction or Form call of a non-GET handler (unless it sets refresh: false), by a non-GET $fetch, by a GraphQL mutation and by a cookie change, so a login or logout ends the cached data. A refresh(key) drops the entries with that key. The cache is off on a static site, where each navigation reads the payload file.
The page above sets meta.staleTime to 30 seconds, so going away from /feed and back within that time shows the same data without running its load.
usePoll(ms, { keys }) calls refresh(keys) on a timer while the tab is visible, and useOnline() tells whether the browser is online; see the API reference. With experimental.offlineRetry: true, a navigation, a prefetch or a useFetch that failed on the network waits for online and tries again after 1, 2, 4 … 30 seconds, for up to 5 minutes. Only GET and HEAD calls are tried again.
Data in a component
useFetch(url, options) and useAsyncData(key, handler, options) fetch inside a component without a handler of the app. Both return data, error, status (idle, pending, success or error), refresh() and execute(), the same function, and clear(), which sets the entry of the key back to idle. useFetch is useAsyncData with $fetch and a key built from the URL and options. Use it for a URL of another service or for a handler that has a route, which has no function to import. While the server renders, a useFetch of a URL that starts with / is answered in process and forwards the request's cookie and authorization headers; a URL with a host does not. Cookies a route sets during SSR are sent with the page: each set-cookie of an in-process answer to useFetch, useApp().fetch or the load's fetch is added to the page response, and later calls of the same request send it. Other headers are not copied, and a call to a URL with a host never adds a cookie. The answer goes into the payload that is sent to the browser, so fetch only what the visitor may read. The handler of a keyed useAsyncData gets { signal }, an AbortSignal that aborts when a newer run replaces it, when the entry is cleared, and when no component uses the key any more; useFetch passes it to $fetch, together with a signal you give it.
import { useAsyncData, useFetch } from "@silgi/react";
interface Comment {
id: number;
text: string;
}
export default function Comments({ postId }: { postId: string }) {
const { data, status, refresh } = useFetch<Comment[]>(`/api/posts/${postId}/comments`, { default: () => [] });
const count = useAsyncData("comment-count", async () => (data ?? []).length, { server: false });
return (
<section>
<p>{status === "pending" ? "Loading" : `${count.data ?? 0} comments`}</p>
<ul>{data?.map((comment) => <li key={comment.id}>{comment.text}</li>)}</ul>
<button type="button" onClick={() => void refresh()}>Reload</button>
</section>
);
}
The key may be left out: useAsyncData(handler, options?) with a function that is not a handler of the app gets one from the build, one per call site, as useState(init) does (State). useAsyncData(fn, input) with a handler of the app keeps the key of the handler and ignores any key the build added.
| Option | Meaning |
|---|---|
server | false skips the call while rendering on the server; the browser runs it |
lazy | do not block rendering or navigation on this data |
default | the value of data while there is no result |
transform | maps the result; the mapped value is what is stored and sent |
immediate | false does not fetch when the component mounts, on the server or in the browser; status stays idle until refresh() or execute() |
dedupe | a refresh while a call is pending: cancel (the default) aborts it and starts again, defer waits for it |
watch | values that call refresh when they change; html has no list. In React and Preact the handler of the latest render runs. Vue's useFetch also re-fetches when its url, query or body refs or getters change; watch: false stops that, and refresh() reads the current values. false does nothing in the other adapters |
pick | top-level keys of an object result to keep, after transform; only they are stored, sent in the payload and returned. A result that is not an object is stored as is |
key | useFetch only: the store key, instead of the one made from the URL and the options |
getCachedData | (key, app, { cause }) returns data to use instead of running the handler, or undefined to run it; cause is initial, refresh:manual (refresh() and execute()), refresh:hook (router.refresh(key)) or watch |
In a production build the error of a server-run call is the one you threw with createError, with its status, message and data. Any other error is a masked error: a 500 Internal Server Error with no data, or the error's own HTTP status (status or statusCode, 400 to 599) with that status's text. The original is logged on the server. The server-rendered HTML shows the masked error too, so the page the browser hydrates is the same. In development the real message is shown. Throw createError for a message the visitor may see, and render only errors made with it.
Data fetched during server rendering goes into the payload, so the browser does not fetch it again while it hydrates. Two components that use the same key share one call. In the browser an entry is dropped once no mounted component uses its key, so a page visited again after a client navigation starts from default and fetches again; the default getCachedData returns the server's data only while the page hydrates. An entry read through your own getCachedData is kept. Return the entry you already have to keep it:
import { useFetch, useRoute } from "@silgi/react";
export default function Author() {
const route = useRoute();
const { data } = useFetch<{ name: string }>(() => `/api/authors/${route.params.id}`, {
getCachedData: (key, app) => app.asyncData.get<{ name: string }>(key)?.data,
});
return <p>{data?.name}</p>;
}
The key of a keyed useAsyncData, the URL of useFetch and its query and body may be getters (in Vue also refs). When a getter's value changes, as from /authors/1 to /authors/2 on the same page, the component moves to the new key and fetches it. In Vue, a ref nested anywhere in query or body, as in { query: { filter: { tags: [tag] } } }, is unwrapped and followed the same way.
useFetch is typed like $fetch: once silgi typegen ran, useFetch("/api/posts/hello").data has the type the handler with that route returns, a URL no route matches gives unknown, and useFetch<Post>(url) still takes the type you give it.
clearAsyncData(app, key?) (from silgi/app, with app from useApp()) resets entries, as on logout: a component showing the key shows default with status: "idle", and its next refresh() fetches again. clearAsyncData(app, fn, input?) resets the entry of a handler.