snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
87
docs/framework/lit/guides/infinite-queries.md
Normal file
87
docs/framework/lit/guides/infinite-queries.md
Normal 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.
|
||||
166
docs/framework/lit/guides/mutations.md
Normal file
166
docs/framework/lit/guides/mutations.md
Normal 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).
|
||||
134
docs/framework/lit/guides/parallel-queries.md
Normal file
134
docs/framework/lit/guides/parallel-queries.md
Normal 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.
|
||||
141
docs/framework/lit/guides/queries.md
Normal file
141
docs/framework/lit/guides/queries.md
Normal 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).
|
||||
81
docs/framework/lit/guides/query-functions.md
Normal file
81
docs/framework/lit/guides/query-functions.md
Normal 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.
|
||||
83
docs/framework/lit/guides/query-invalidation.md
Normal file
83
docs/framework/lit/guides/query-invalidation.md
Normal 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).
|
||||
85
docs/framework/lit/guides/query-keys.md
Normal file
85
docs/framework/lit/guides/query-keys.md
Normal 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.
|
||||
111
docs/framework/lit/guides/reactive-controllers-vs-hooks.md
Normal file
111
docs/framework/lit/guides/reactive-controllers-vs-hooks.md
Normal 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.
|
||||
145
docs/framework/lit/guides/ssr.md
Normal file
145
docs/framework/lit/guides/ssr.md
Normal 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.
|
||||
Reference in New Issue
Block a user