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,
})
}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.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>`
}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),
}))
}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)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:
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.