snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
Some checks failed
autofix.ci / autofix (push) Failing after 4s
Release / Release (push) Has been skipped
GitHub Actions Security Analysis / Run zizmor (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:13:08 +05:00
commit 3a013ccdfe
3570 changed files with 490194 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
---
id: infinite-queries
title: Infinite Queries
---
Infinite queries are for lists that load more data into one cache entry. Use [`createInfiniteQueryController`](../reference/functions/createInfiniteQueryController.md).
An infinite query result contains:
- `data.pages`: fetched pages
- `data.pageParams`: page parameters used for those pages
- `fetchNextPage` and `fetchPreviousPage`
- `hasNextPage` and `hasPreviousPage`
- `isFetchingNextPage` and `isFetchingPreviousPage`
## Load More Example
```ts
import { LitElement, html } from 'lit'
import { createInfiniteQueryController } from '@tanstack/lit-query'
class ProjectsList extends LitElement {
private readonly projects = createInfiniteQueryController(this, {
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjectsPage(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.page + 1 : undefined,
})
render() {
const query = this.projects()
if (query.isPending) return html`Loading...`
if (query.isError) return html`Error: ${query.error.message}`
return html`
${query.data.pages.map(
(page) => html`
${page.projects.map((project) => html`<p>${project.name}</p>`)}
`,
)}
<button
?disabled=${!query.hasNextPage || query.isFetching}
@click=${() => this.projects.fetchNextPage()}
>
${query.isFetchingNextPage
? 'Loading more...'
: query.hasNextPage
? 'Load More'
: 'Nothing more to load'}
</button>
`
}
}
```
## Page Parameters
`initialPageParam` is required. `getNextPageParam` decides whether another page exists and what value should be passed as `pageParam` to the next query function call.
```ts
createInfiniteQueryController(this, {
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjectsPage(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.page + 1 : undefined,
})
```
Returning `undefined` or `null` means there is no next page.
## Avoid Overlapping Fetches
There is one ongoing fetch for an infinite query cache entry. If you call `fetchNextPage` while a background refetch is running, you can overwrite data. Disable the button or check `!query.isFetching` before loading more:
```ts
if (query.hasNextPage && !query.isFetching) {
this.projects.fetchNextPage()
}
```
## Paginated Alternative
If your UI shows one page at a time, a normal query with a page in the key can be a better fit. The [Pagination example](../examples/pagination) uses `createQueryController`, `placeholderData: keepPreviousData`, prefetching, and mutations to demonstrate that pattern.

View File

@@ -0,0 +1,166 @@
---
id: mutations
title: Mutations
---
Unlike queries, mutations are used to create, update, delete, or otherwise perform server side effects. In Lit, use [`createMutationController`](../reference/functions/createMutationController.md).
```ts
import { LitElement, html } from 'lit'
import {
QueryClient,
QueryClientProvider,
createMutationController,
createQueryController,
} from '@tanstack/lit-query'
const queryClient = new QueryClient()
class AppQueryProvider extends QueryClientProvider {
constructor() {
super()
this.client = queryClient
}
}
customElements.define('app-query-provider', AppQueryProvider)
class TodosView extends LitElement {
private readonly todos = createQueryController(this, {
queryKey: ['todos'],
queryFn: fetchTodos,
})
private readonly addTodo = createMutationController(this, {
mutationFn: createTodo,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
render() {
const query = this.todos()
const mutation = this.addTodo()
const todos = query.data ?? []
return html`
${mutation.isError ? html`<p>${mutation.error.message}</p>` : null}
${mutation.isSuccess ? html`<p>Todo added</p>` : null}
<button
?disabled=${mutation.isPending}
@click=${() => this.addTodo.mutate({ title: 'Write mutation docs' })}
>
${mutation.isPending ? 'Adding...' : 'Add Todo'}
</button>
<ul>
${todos.map((todo) => html`<li>${todo.title}</li>`)}
</ul>
`
}
}
customElements.define('todos-view', TodosView)
```
Render the element under the provider so the controllers can resolve the same `QueryClient` from Lit context:
```html
<app-query-provider>
<todos-view></todos-view>
</app-query-provider>
```
## Mutation States
A mutation can be in one of these primary states:
- `isIdle` or `status === 'idle'`: no mutation has run or it has been reset
- `isPending` or `status === 'pending'`: the mutation is running
- `isError` or `status === 'error'`: the mutation failed and `error` is available
- `isSuccess` or `status === 'success'`: the mutation finished and `data` is available
## Variables
Pass variables to the mutation function by calling `mutate`:
```ts
this.addTodo.mutate({
title: this.nextTitle,
})
```
`mutate` throws synchronously if the controller cannot resolve a `QueryClient`, such as when the element is not under a connected `QueryClientProvider` and no explicit client was passed. `mutateAsync` reports the same setup problem as a rejected promise.
Use `mutateAsync` when you want a promise:
```ts
try {
const created = await this.addTodo.mutateAsync({ title: this.nextTitle })
this.nextTitle = created.title
} catch (error) {
this.errorMessage = String(error)
}
```
## Resetting Mutation State
The accessor includes `reset`:
```ts
html`
${mutation.isError
? html`<button @click=${() => this.addTodo.reset()}>Clear error</button>`
: null}
`
```
## Side Effects
Mutation options support `onMutate`, `onError`, `onSuccess`, and `onSettled`. The pagination example passes an explicit `queryClient` to the controller and uses the same in-scope client for optimistic updates and rollback:
```ts
private readonly favoriteMutation = createMutationController(
this,
{
mutationKey: ['toggle-project-favorite'],
mutationFn: async (input) => {
const response = await toggleProjectFavoriteOnServer(input)
return response.project
},
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ['projects'] })
const snapshots = queryClient.getQueriesData<ProjectsPageResponse>({
queryKey: ['projects'],
})
for (const [key, existing] of snapshots) {
if (!existing) continue
queryClient.setQueryData<ProjectsPageResponse>(key, {
...existing,
projects: existing.projects.map((project) =>
project.id === variables.id
? { ...project, isFavorite: variables.isFavorite }
: project,
),
})
}
return { snapshots }
},
onError: (_error, _variables, context) => {
for (const [key, snapshot] of context?.snapshots ?? []) {
queryClient.setQueryData(key, snapshot)
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ['projects'] })
},
},
queryClient,
)
```
For the exact runnable flow, see the [Pagination example](../examples/pagination).

View File

@@ -0,0 +1,134 @@
---
id: parallel-queries
title: Parallel Queries
---
Parallel queries are queries that run at the same time so the UI does not wait for one request before starting the next.
## Manual Parallel Queries
When the number of queries is fixed, create multiple query controllers on the same host. They will all subscribe when the host connects.
```ts
import { LitElement, html } from 'lit'
import { createQueryController } from '@tanstack/lit-query'
class DashboardView extends LitElement {
private readonly users = createQueryController(this, {
queryKey: ['users'],
queryFn: fetchUsers,
})
private readonly teams = createQueryController(this, {
queryKey: ['teams'],
queryFn: fetchTeams,
})
private readonly projects = createQueryController(this, {
queryKey: ['projects'],
queryFn: fetchProjects,
})
render() {
const users = this.users()
const teams = this.teams()
const projects = this.projects()
if (users.isPending || teams.isPending || projects.isPending) {
return html`Loading...`
}
if (users.isError || teams.isError || projects.isError) {
return html`Unable to load dashboard`
}
return html`
<dashboard-summary
.users=${users.data}
.teams=${teams.data}
.projects=${projects.data}
></dashboard-summary>
`
}
}
```
Each controller receives the same `ReactiveControllerHost`. If no explicit `QueryClient` is passed, each controller resolves the nearest connected `QueryClientProvider`.
## Dynamic Parallel Queries
When the number of queries changes with host state, use [`createQueriesController`](../reference/functions/createQueriesController.md). It accepts a `queries` array and returns an accessor for the array of query results.
Use an options getter when the query list depends on reactive host fields:
```ts
import { LitElement, html } from 'lit'
import { createQueriesController } from '@tanstack/lit-query'
class UsersDetails extends LitElement {
static properties = {
userIds: { attribute: false },
}
userIds: Array<string> = []
private readonly users = createQueriesController(this, () => ({
queries: this.userIds.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUserById(id),
})),
}))
render() {
const userQueries = this.users()
return html`
<ul>
${userQueries.map((query, index) => {
if (query.isPending) return html`<li>Loading...</li>`
if (query.isError) return html`<li>Error loading user</li>`
return html`<li>${this.userIds[index]}: ${query.data.name}</li>`
})}
</ul>
`
}
}
```
The order of the results matches the order of the input queries.
## Combining Results
Use `combine` when a component wants one derived value instead of an array of query results:
```ts
private readonly dashboard = createQueriesController(this, {
queries: [
{ queryKey: ['stats'], queryFn: fetchStats },
{ queryKey: ['projects'], queryFn: fetchProjects },
],
combine: ([stats, projects]) => ({
activeUsers: stats.data?.activeUsers ?? 0,
projects: projects.data ?? [],
isPending: stats.isPending || projects.isPending,
isError: stats.isError || projects.isError,
}),
})
```
```ts
render() {
const dashboard = this.dashboard()
if (dashboard.isPending) return html`Loading...`
if (dashboard.isError) return html`Unable to load dashboard`
return html`
<p>Total projects: ${dashboard.projects.length}</p>
<p>Active users: ${dashboard.activeUsers}</p>
`
}
```
Having the same query key more than once in the `queries` array can cause those entries to share cached data. Deduplicate repeated keys first if each rendered row needs independent query state.

View File

@@ -0,0 +1,141 @@
---
id: queries
title: Queries
---
New to Lit Query? Start with [Installation](../installation.md) and [Quick Start](../quick-start.md) before wiring query controllers into your elements.
## Query Basics
A query is a declarative dependency on an asynchronous source of data tied to a unique key. Use queries for reading server state. If a function creates, updates, or deletes server data, use a [mutation](./mutations.md) instead.
In Lit, subscribe to a query with [`createQueryController`](../reference/functions/createQueryController.md):
```ts
import { LitElement, html } from 'lit'
import { createQueryController } from '@tanstack/lit-query'
class TodosView extends LitElement {
private readonly todos = createQueryController(this, {
queryKey: ['todos'],
queryFn: fetchTodos,
})
render() {
const query = this.todos()
if (query.isPending) return html`Loading...`
if (query.isError) return html`Error: ${query.error.message}`
return html`
<ul>
${query.data.map((todo) => html`<li>${todo.title}</li>`)}
</ul>
`
}
}
```
The controller needs:
- A `ReactiveControllerHost`, usually `this` inside a `LitElement`
- A unique `queryKey`
- A `queryFn` that returns a promise and throws on errors
The returned accessor exposes the current `QueryObserverResult`. Call it in `render`, or read `.current`:
```ts
const query = this.todos()
const sameQuery = this.todos.current
```
## Query States
A query can be in one primary state at a time:
- `isPending` or `status === 'pending'`: no data is available yet
- `isError` or `status === 'error'`: the query failed and `error` is available
- `isSuccess` or `status === 'success'`: data is available
The result also includes `isFetching`, which can be true during the initial load or a background refetch.
```ts
render() {
const query = this.todos()
if (query.status === 'pending') {
return html`<span>Loading...</span>`
}
if (query.status === 'error') {
return html`<span>Error: ${query.error.message}</span>`
}
return html`<todo-list .items=${query.data}></todo-list>`
}
```
TypeScript will narrow `query.data` after you check `pending` and `error` before reading it.
## Fetch Status
The `status` field describes whether data is available. The `fetchStatus` field describes what the query function is doing:
- `fetchStatus === 'fetching'`: the query is currently fetching.
- `fetchStatus === 'paused'`: the query wanted to fetch, but fetching is paused.
- `fetchStatus === 'idle'`: the query is not fetching.
These states are intentionally separate. Background refetching and stale-while-revalidate behavior can produce combinations like:
- A successful query with cached data can have `status === 'success'` and `fetchStatus === 'fetching'` while a background refetch is running.
- A query with no data can have `status === 'pending'` and `fetchStatus === 'paused'` if fetching cannot start yet.
Use `status` when deciding whether data can be rendered, and use `fetchStatus` or `isFetching` when deciding whether to show a network activity indicator:
```ts
render() {
const query = this.todos()
if (query.isPending) return html`Loading...`
if (query.isError) return html`Error: ${query.error.message}`
return html`
${query.fetchStatus === 'fetching'
? html`<span>Refreshing...</span>`
: null}
<todo-list .items=${query.data}></todo-list>
`
}
```
## Reactive Query Options
Use an options getter when the query key or query function depends on host state:
```ts
class UserTodos extends LitElement {
static properties = {
userId: { type: String },
}
userId = ''
private readonly todos = createQueryController(this, () => ({
queryKey: ['todos', this.userId],
queryFn: () => fetchTodos(this.userId),
enabled: this.userId.length > 0,
}))
}
```
The query key is used for caching, refetching, and sharing data between controllers.
## Refetching
The accessor includes `refetch`:
```ts
html`<button @click=${() => this.todos.refetch()}>Refetch</button>`
```
For multiple queries that should run at the same time, see [Parallel Queries](./parallel-queries.md).

View File

@@ -0,0 +1,81 @@
---
id: query-functions
title: Query Functions
---
A query function can be any function that returns a promise. The promise should resolve data or throw an error.
```ts
createQueryController(this, {
queryKey: ['todos'],
queryFn: fetchTodos,
})
createQueryController(this, () => ({
queryKey: ['todo', this.todoId],
queryFn: () => fetchTodo(this.todoId),
}))
```
## Handling Errors
TanStack Query needs failed query functions to throw or return a rejected promise. Some clients do that automatically. The browser `fetch` API does not, so check `response.ok` yourself:
```ts
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new Error('Failed to fetch todos')
}
return response.json() as Promise<Todo[]>
}
```
The thrown error is available on the query result:
```ts
const query = this.todos()
if (query.isError) {
return html`Error: ${query.error.message}`
}
```
## Query Function Context
TanStack Query passes a context object to every query function. It includes:
- `queryKey`: the current query key
- `client`: the `QueryClient`
- `signal`: an `AbortSignal` for cancellation
- `meta`: optional query metadata
```ts
createQueryController(this, {
queryKey: ['todos', { status: 'open' }],
queryFn: async ({ queryKey, signal }) => {
const [, filters] = queryKey
const response = await fetch(`/api/todos?status=${filters.status}`, {
signal,
})
if (!response.ok) throw new Error('Failed to fetch todos')
return response.json() as Promise<Todo[]>
},
})
```
Infinite query functions also receive `pageParam`:
```ts
createInfiniteQueryController(this, {
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjectsPage(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.hasMore ? lastPage.page + 1 : undefined,
})
```
See [Infinite Queries](./infinite-queries.md) for the controller-specific behavior.

View File

@@ -0,0 +1,83 @@
---
id: query-invalidation
title: Query Invalidation
---
Waiting for queries to become stale is not always enough. After a mutation succeeds, you often know that related cached data is out of date. Use `queryClient.invalidateQueries` to mark matching queries stale and refetch active observers.
```ts
queryClient.invalidateQueries()
queryClient.invalidateQueries({
queryKey: ['todos'],
})
```
When a query is invalidated:
- It is marked stale, overriding any `staleTime`
- If a matching query is active in a controller, it can refetch in the background
## Invalidation from Mutations
```ts
private readonly addTodo = createMutationController(this, {
mutationFn: addTodo,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
```
Use this pattern when the mutation result tells you related cached data is stale. The [Pagination example](../examples/pagination) shows invalidation after project mutations.
## Query Matching
Match a group of queries by prefix:
```ts
queryClient.invalidateQueries({ queryKey: ['projects'] })
```
Both of these query keys match:
```ts
const projectsListKey = ['projects']
const projectsPageKey = ['projects', 1, 250, false]
```
Use a more specific key when only one slice should be invalidated:
```ts
queryClient.invalidateQueries({
queryKey: ['projects', this.page],
})
```
Use `exact: true` to match only the exact key:
```ts
queryClient.invalidateQueries({
queryKey: ['projects'],
exact: true,
})
```
## Manual Cache Updates
Invalidation is usually simpler than normalized cache updates. When you do need immediate UI updates, combine targeted cache writes with invalidation:
```ts
queryClient.setQueryData<TodosResponse>(['todos'], (existing) => {
if (!existing) return existing
return {
...existing,
items: [...existing.items, createdTodo],
}
})
await queryClient.invalidateQueries({ queryKey: ['todos'] })
```
For rollback with optimistic updates, see the mutation guide and the [Pagination example](../examples/pagination).

View File

@@ -0,0 +1,85 @@
---
id: query-keys
title: Query Keys
---
TanStack Query manages caching by query key. Query keys must be arrays at the top level, and they should uniquely describe the data returned by the query function.
## Simple Keys
Use simple keys for list resources or non-hierarchical data:
```ts
createQueryController(this, {
queryKey: ['todos'],
queryFn: fetchTodos,
})
createQueryController(this, {
queryKey: ['settings'],
queryFn: fetchSettings,
})
```
## Keys with Variables
Include variables when they change what the query fetches:
```ts
createQueryController(this, () => ({
queryKey: ['todo', this.todoId],
queryFn: () => fetchTodo(this.todoId),
}))
createQueryController(this, () => ({
queryKey: ['projects', { page: this.page, filter: this.filter }],
queryFn: () => fetchProjects({ page: this.page, filter: this.filter }),
}))
```
The pagination example uses a key shaped like this:
```ts
type ProjectsQueryKey = readonly ['projects', number, number, boolean]
function projectsQueryKey(
page: number,
delayMs: number,
forceError: boolean,
): ProjectsQueryKey {
return ['projects', page, delayMs, forceError] as const
}
```
## Deterministic Hashing
Object key order does not matter inside a query key:
```ts
const keyA = ['todos', { status, page }]
const keyB = ['todos', { page, status }]
```
Array item order does matter:
```ts
const keyA = ['todos', status, page]
const keyB = ['todos', page, status]
```
## Query Keys as Dependencies
If your query function reads a reactive host property, include that value in the query key:
```ts
class UserTodos extends LitElement {
userId = ''
private readonly todos = createQueryController(this, () => ({
queryKey: ['todos', this.userId],
queryFn: () => fetchTodos(this.userId),
}))
}
```
This lets Lit Query cache each user's todos separately and refetch when the host state changes.

View File

@@ -0,0 +1,111 @@
---
id: reactive-controllers-vs-hooks
title: Reactive Controllers vs Hooks
---
React Query examples use hooks. Lit Query uses reactive controllers.
The job is similar: subscribe a component to a `QueryClient`, read the latest result, and update the component when the cache changes. The integration point is different because Lit components use the `ReactiveControllerHost` interface instead of React's render and hook system.
## Mapping the Concepts
| React Query | Lit Query |
| --------------------------- | ---------------------------------------------- |
| `useQuery(options)` | `createQueryController(this, options)` |
| `useQueries(options)` | `createQueriesController(this, options)` |
| `useMutation(options)` | `createMutationController(this, options)` |
| `useInfiniteQuery(options)` | `createInfiniteQueryController(this, options)` |
| `useIsFetching(options)` | `useIsFetching(this, options)` |
| `useIsMutating(options)` | `useIsMutating(this, options)` |
| `useMutationState(options)` | `useMutationState(this, options)` |
| Hook result object | Callable result accessor |
| React context provider | `QueryClientProvider` custom element |
| Component render rerun | `host.requestUpdate()` |
## Host-Bound APIs
Lit APIs that subscribe a component to query or mutation state receive a `host` as the first argument:
```ts
class TodosView extends LitElement {
private readonly todos = createQueryController(this, {
queryKey: ['todos'],
queryFn: fetchTodos,
})
}
```
`this` is valid because `LitElement` implements `ReactiveControllerHost`. The controller attaches to the host, subscribes when the host connects, requests updates when the query result changes, and unsubscribes when the host disconnects.
The host-bound APIs are [`createQueryController`](../reference/functions/createQueryController.md), [`createQueriesController`](../reference/functions/createQueriesController.md), [`createInfiniteQueryController`](../reference/functions/createInfiniteQueryController.md), [`createMutationController`](../reference/functions/createMutationController.md), [`useIsFetching`](../reference/functions/useIsFetching.md), [`useIsMutating`](../reference/functions/useIsMutating.md), and [`useMutationState`](../reference/functions/useMutationState.md).
[`useQueryClient`](../reference/functions/useQueryClient.md) is different. It is not a reactive controller, does not accept a host, does not subscribe, and throws synchronously if no single default client is available. Use it only for imperative code that runs while exactly one `QueryClientProvider` is connected. Inside host-bound APIs, prefer the provider context or pass an explicit `QueryClient`.
## Reading Results
Lit Query controller creators return a callable accessor with a `current` property:
```ts
const query = this.todos()
const sameQuery = this.todos.current
```
Render methods normally call the accessor:
```ts
render() {
const query = this.todos()
if (query.isPending) return html`Loading...`
if (query.isError) return html`Error: ${query.error.message}`
return html`<todo-list .items=${query.data}></todo-list>`
}
```
## Reactive Options
If query options depend on host state, pass a function. Lit Query re-reads function accessors during host updates:
```ts
class ProjectView extends LitElement {
static properties = {
projectId: { type: Number },
}
projectId = 1
private readonly project = createQueryController(this, () => ({
queryKey: ['project', this.projectId],
queryFn: () => fetchProject(this.projectId),
}))
}
```
If options are static, pass an object. If you mutate a static options object yourself, call the controller helper that causes the observer to see the new options, such as `refetch`, or prefer a function accessor for reactive state.
## Provider Context
Host-bound APIs can receive an explicit `QueryClient`, but most apps render under [`QueryClientProvider`](../reference/classes/QueryClientProvider.md). The provider uses Lit context to deliver the client to descendant controllers.
```ts
customElements.define('query-client-provider', QueryClientProvider)
```
```ts
html`
<query-client-provider .client=${queryClient}>
<todos-view></todos-view>
</query-client-provider>
`
```
Custom element registration is always the application's responsibility.
`QueryClientProvider` also registers its client in a process-local fallback store for [`useQueryClient`](../reference/functions/useQueryClient.md) and [`resolveQueryClient`](../reference/functions/resolveQueryClient.md). That fallback is intentionally conservative:
- If no provider is connected, `useQueryClient()` throws.
- If exactly one distinct client is connected, `useQueryClient()` returns it.
- If multiple distinct clients are connected in the same JavaScript context, `useQueryClient()` and `resolveQueryClient()` throw because the fallback would be ambiguous.
Multiple roots, micro-frontends, test suites with shared modules, and nested apps should avoid relying on the process-local fallback. Render host-bound controllers under the right provider, pass an explicit `QueryClient` to the controller, or cleanly disconnect providers between tests.

View File

@@ -0,0 +1,145 @@
---
id: ssr
title: Server Rendering & Hydration
---
Lit Query can be used with server rendering by combining Lit SSR with TanStack Query Core hydration APIs re-exported from `@tanstack/lit-query`.
The runnable source for this guide is the [SSR example](../examples/ssr).
## Flow
Server rendering has three phases:
1. Create a per-request `QueryClient`.
2. Prefetch queries on the server and render Lit HTML with that client.
3. Dehydrate the cache into the HTML, then hydrate a browser `QueryClient` before the client app renders.
Never share one server `QueryClient` between users or requests.
## Server Prefetch and Render
```ts
import { render } from '@lit-labs/ssr'
import { collectResult } from '@lit-labs/ssr/lib/render-result.js'
import { html } from 'lit'
import { QueryClient, dehydrate, noop } from '@tanstack/lit-query'
import { createDataQueryOptions } from './api.js'
import './app.js'
async function renderPage() {
const apiBaseUrl = 'https://example.com'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
},
},
})
await queryClient.query(createDataQueryOptions(apiBaseUrl)).catch(noop)
const appHtml = await collectResult(
render(
html`<ssr-app
api-base-url=${apiBaseUrl}
.queryClient=${queryClient}
></ssr-app>`,
),
)
const dehydratedState = dehydrate(queryClient)
return { appHtml, dehydratedState }
}
```
The server passes the same client into the Lit element with a property binding. This lets `createQueryController` read the prefetched cache during server render. If your query function calls `fetch` during SSR, pass an absolute API origin instead of relying on a browser-relative URL.
## Client Hydration
```ts
import '@lit-labs/ssr-client/lit-element-hydrate-support.js'
import { QueryClient, hydrate, type DehydratedState } from '@tanstack/lit-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
},
},
})
const dehydratedState = JSON.parse(
document.getElementById('__QUERY_STATE__')?.textContent ?? 'null',
) as DehydratedState
queryClient.mount()
hydrate(queryClient, dehydratedState)
const appElement = document.querySelector('ssr-app') as
| (HTMLElement & { queryClient?: QueryClient })
| null
if (!appElement) {
throw new Error('Expected the SSR app element to exist before hydration.')
}
appElement.queryClient = queryClient
await import('./app.js')
```
Unmount the client when the page is unloaded if you mounted it manually:
```ts
window.addEventListener(
'pagehide',
() => {
queryClient.unmount()
},
{ once: true },
)
```
## Component Pattern
The SSR example creates its controller only after a `queryClient` property is available:
```ts
import { LitElement } from 'lit'
import {
createQueryController,
type QueryClient,
type QueryResultAccessor,
} from '@tanstack/lit-query'
import { createDataQueryOptions, type DataResponse } from './api.js'
class SsrApp extends LitElement {
static properties = {
apiBaseUrl: { attribute: 'api-base-url' },
queryClient: { attribute: false },
}
apiBaseUrl = ''
queryClient?: QueryClient
private dataQuery?: QueryResultAccessor<DataResponse, Error>
protected override willUpdate(): void {
if (!this.dataQuery && this.queryClient) {
this.dataQuery = createQueryController(
this,
createDataQueryOptions(this.apiBaseUrl),
this.queryClient,
)
}
}
}
```
This explicit-client pattern is useful for SSR because the client is created by the renderer rather than discovered from a connected DOM provider.
## Serialization
Embed dehydrated state as JSON in the HTML and escape characters that can break out of a script tag. The example server uses a small serializer before replacing `__QUERY_STATE_JSON__` in the built HTML template.
Lit Query re-exports `dehydrate` and `hydrate` from TanStack Query Core. Use `dehydrate(queryClient)` after server prefetching to capture the cache state. In the browser, parse that state, create a fresh `QueryClient`, call `hydrate(queryClient, dehydratedState)`, assign the client to the server-rendered element, and only then import the Lit component so it upgrades with the prefetched cache available.