Preloading starts a named query before the component that needs it mounts. The browser can receive its rows during a route transition or a link hover. The destination component then reads the query through the usual Rindle hooks.
Two related APIs serve different stages:
| API | Where it runs | What it prepares |
|---|---|---|
app.ensure(query, options) |
Browser | A live named query and a temporary retain |
createServerStore(...).preload(...) |
Server | A one-shot snapshot for the initial HTML |
The TanStack adapter combines these paths in a route loader. This page first explains browser readiness, then shows the route options. For server snapshots and their lifetime, read Server rendering.
Define a query to preload
This example uses the issue table from the SSR guide:
a numeric id and a string title. Add this query module:
// shared/issue-detail.ts
import { defineQuery, newQueryBuilder } from "@rindle/client";
import { schema } from "./schema.gen.ts";
const q = newQueryBuilder(schema);
export const issueByIdQuery = defineQuery(
"issueById",
(raw): number => {
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 1) {
throw new Error("Expected a positive integer issue ID");
}
return raw;
},
(id) => q.issue.where.id(id).one(),
);
Register this query on the API server alongside recentIssuesQuery. In the
SSR example’s server/ssr-api.ts, replace the queries option with:
// Add this import to server/ssr-api.ts:
import { issueByIdQuery } from "../shared/issue-detail.ts";
// Inside createRindleApiServer({ ... }):
queries: registerQueries<undefined>([recentIssuesQuery, issueByIdQuery]),
This is a modification to the defined API factory,
not a standalone module. The server must recognize every preloaded name.
An ad-hoc local builder query has no remote identity, so ensure rejects it.
Choose when the browser can continue
The client exposes ensure through the object returned by createRindleClient.
It resolves to void. It prepares the query rather than returning its rows.
// src/prepare-issue.ts
import { bootClient } from "./rindle-client.ts";
import { issueByIdQuery } from "../shared/issue-detail.ts";
export async function prepareIssue(id: number, signal?: AbortSignal) {
const app = await bootClient();
await app.ensure(issueByIdQuery(id), {
until: "present",
signal,
});
}
bootClient is the browser startup function
defined in the SSR guide. Existing synced apps can use their client instance
directly.
| Policy | When the promise resolves | What the result means |
|---|---|---|
"complete" |
The server marks the query complete | This query received its authoritative result, which can be empty |
"present" |
The local view has a result, or the server marks it complete | Available rows can be partial, stale, or optimistic |
For a plural query, a local result means a nonempty array. For .one(), it means
a non-null row. An empty local view alone does not satisfy "present", because
the server can still have matching rows. A confirmed empty result satisfies both
policies.
The defaults differ: app.ensure(query) defaults to "complete".
rindle.loader({ query }) defaults to "present".
Use "present" when available content makes a useful navigation result. Use
"complete" when the page needs the server’s answer before it decides what to
show. Completeness describes that query’s server channel, not an absence of
pending optimistic writes or replication lag.
Resolving a "present" wait does not change the query’s status to "complete".
The destination can display local rows while useQueryStatus still reports
"unknown".
Declare client and server route data
The following route uses the adapter instance
named rindle. It waits for the selected issue during browser navigation.
It also preloads the recent-issue list for the initial server render:
// src/routes/issues.$id.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useQuery, useQueryStatus } from "@rindle/react";
import { issueByIdQuery } from "../../shared/issue-detail.ts";
import { IssueList } from "../IssueList.tsx";
import { recentIssuesQuery } from "../../shared/queries.ts";
import { rindle } from "../rindle-tanstack.ts";
export const Route = createFileRoute("/issues/$id")({
loader: rindle.loader({
query: ({ params }) => issueByIdQuery(Number(params.id)),
ssr: () => recentIssuesQuery(),
until: "present",
}),
component: IssuePage,
});
function IssuePage() {
const { id } = Route.useParams();
const query = issueByIdQuery(Number(id));
const issue = useQuery(query);
const status = useQueryStatus(query);
return (
<main>
{issue ? <h1>{issue.title}</h1>
: <p>{status === "complete" ? "Issue not found." : "Loading issue…"}</p>}
<aside>
<h2>Recent issues</h2>
<IssueList />
</aside>
</main>
);
}
| Loader option | Server render | Browser navigation |
|---|---|---|
query |
Included in the seed | Retained and awaited with ensure |
ssr |
Included in the seed | Ignored by the loader |
until |
Does not change the server read | Selects readiness for query |
Here, IssueList receives seeded data on the first page load. On later browser
navigation, its own hook starts the query when it mounts. The ssr declaration
does not preload that query during client navigation.
A loader can return arrays from either factory. For example, replace its query
option with this to wait for both views on browser navigation:
query: ({ params }) => [
issueByIdQuery(Number(params.id)),
recentIssuesQuery(),
],
This is a route-option fragment. The adapter preloads the union of query and
ssr on the server, with duplicate query identities removed. In the browser,
it waits for all query results concurrently. At least one factory is required.
Cancel a wait or set a deadline
TanStack loaders pass their abortController.signal to ensure. With the
client API directly, pass your own signal. For example, this helper limits the
wait to ten seconds:
// src/prepare-with-deadline.ts
import { prepareIssue } from "./prepare-issue.ts";
export async function prepareIssueWithDeadline(id: number) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
try {
await prepareIssue(id, controller.signal);
} finally {
clearTimeout(timer);
}
}
ensure has no built-in timeout. Aborting rejects this caller’s wait with an
AbortError. It does not cancel a shared subscription or another caller’s wait.
Route-level error handling decides what to show after a rejected wait.
Retention and cleanup
Concurrent waits for the same name, arguments, and local query definition share a preload entry. They can use different readiness policies.
The client normally keeps a completed preload for another ten seconds. This
gives the destination time to acquire its own subscription. A "present" result
normally remains retained while server confirmation is pending.
The cache also limits retained entries to 32, evicting entries without active waiters when needed. These are preload-cache defaults, not guarantees that every visited query stays in memory. Components own their subscriptions after mounting. Closing the client releases preloads and rejects unfinished waits.
Next steps
- TanStack Start — provider, route wiring, and the query endpoint.
- Server rendering — initial snapshots and authenticated server reads.
- The browser client — query status, pending writes, and local data.