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.
| 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() |
Lit APIs that subscribe a component to query or mutation state receive a host as the first argument:
class TodosView extends LitElement {
private readonly todos = createQueryController(this, {
queryKey: ['todos'],
queryFn: fetchTodos,
})
}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, createQueriesController, createInfiniteQueryController, createMutationController, useIsFetching, useIsMutating, and useMutationState.
useQueryClient 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.
Lit Query controller creators return a callable accessor with a current property:
const query = this.todos()
const sameQuery = this.todos.currentconst query = this.todos()
const sameQuery = this.todos.currentRender methods normally call the accessor:
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>`
}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>`
}If query options depend on host state, pass a function. Lit Query re-reads function accessors during host updates:
class ProjectView extends LitElement {
static properties = {
projectId: { type: Number },
}
projectId = 1
private readonly project = createQueryController(this, () => ({
queryKey: ['project', this.projectId],
queryFn: () => fetchProject(this.projectId),
}))
}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.
Host-bound APIs can receive an explicit QueryClient, but most apps render under QueryClientProvider. The provider uses Lit context to deliver the client to descendant controllers.
customElements.define('query-client-provider', QueryClientProvider)customElements.define('query-client-provider', QueryClientProvider)html`
<query-client-provider .client=${queryClient}>
<todos-view></todos-view>
</query-client-provider>
`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 and resolveQueryClient. That fallback is intentionally conservative:
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.