Skip to content
Guides contents

GuidesUI & local state

Preload & navigate

Preload named queries during navigation and reuse available local rows while server subscriptions load.

View as Markdown

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