Ramonda

Queries on the server

A server render waits for what a query fetches, so the data is in the page. Then it travels to the client, which renders it on its first pass and does not fetch it again. There is nothing to configure for any of that.

class UserCard extends Component<{ id: string }> {
  private user = this.use(Query, (self: UserCard) => ({
    key: ["user", self.props.id],
    fetch: self.load,
  }));

  load({ signal }: FetchContext) {
    return api.getUser(this.props.id, { signal });
  }

  render() {
    return <p>{this.user.data?.name ?? "…"}</p>;
  }
}

renderToString on that produces the name, not a spinner.

How it works, and why there is no API for it

Two mechanisms the framework already has, and neither is new here.

Waiting is async on the server: a lifecycle method that returns a promise is awaited during a server render. The query's @mounted returns the fetch, so the render waits for it and for whatever that starts, then serializes.

Travelling is the hydration blob. Every hook's @state is serialized per component and restored before any client render — so the query keeps its answer in a @state field and it rides along with everything else the component was holding. That is why there is no dehydrate() to call, no boundary component to place, and no script tag to add: the transport already existed.

The cost is honest: two observers of one key each carry a copy. Bytes, not correctness — and if that matters for a page, the explicit route is exported.

Why the first render matters

The client's first render has to produce what the server produced. If it rendered a spinner instead, hydration would find markup that disagrees with it and resolve that by throwing the server's markup away — the reader watches finished content flash into a placeholder, and the framework reports it as RMD007.

So the restored data is put into the cache on the read path, before anything renders, rather than from a lifecycle that runs after the first commit. Nothing to do; it is worth knowing because it is the reason the pattern works at all.

Server data counts as fresh

staleTime defaults to 0, which means "stale the moment it arrives". Taken literally on hydration that would refetch every query on the page the instant it became interactive — doubling every request, on the most common setup there is.

So data that came from the server is treated as being as fresh as the document that carried it: mounting does not refresh it. Ask for both explicitly if you want them:

{ refetchOnMount: "always" }   // render the server's data, then refresh it

isRestored tells you where the data came from, for a UI that wants to say so.

Timestamps are not compared across the boundary, and that is on purpose: the server stamps its clock, the client reads its own, and two machines' clocks differ by seconds routinely — so a restored entry would look either fresher or staler than it is, at random. The restore stamps the client's clock instead.

Failures cross too

A fetch that failed on the server renders its failure on the client, rather than a spinner that never resolves. It has to be translated to get there: JSON.stringify(new Error("nope")) is {}name, message and stack are all non-enumerable — so the failure travels as { name, message } and arrives as a ServerQueryError, a real Error subclass, so error.message and error instanceof Error keep working.

stack is deliberately not carried: it points into the server's bundle, which no client-side source map can resolve, and a stack that leads nowhere is worse than an honest absence.

Flattening the waterfall

A server render gives up after ten sequential rounds of async work, because that many round trips in a row is a waterfall worth surfacing rather than absorbing into a response time nobody can explain.

The fix is to load what a page needs in one place:

class TodosPage extends Component {
  private queries = this.use(QueryClientAccess);

  @mounted
  load() {
    // Returned, so the server waits for it. The children below find their data
    // already cached instead of each fetching what the one above just learned.
    return this.queries.client.prefetch(["todos"], loadTodos);
  }
}

Sending the cache once

For a server that would rather fetch outside the tree and hand the cache over — prefetching in a route handler, sending one payload instead of one per observer — dehydrate and hydrate are exported:

// server
const client = new QueryClient();
await client.prefetch(["todos"], loadTodos);
const html = await renderToString(<App client={client} />);
const payload = JSON.stringify(client.dehydrate());
// client
const client = new QueryClient();
client.hydrate(JSON.parse(payload));
// …and pass it in: this.use(QueryClientProvider, () => ({ client }))

hydrate will not overwrite data the client already fetched itself — a page that loaded something before the payload arrived knows better than the payload does.

What does not run on the server

Effects never do, so focus and reconnect refetching and polling are client-only by construction. enabled is read once, when the query mounts, which is the only moment a server render has.

Next