Files
upstream-tanstack-query/docs/framework/lit/guides/reactive-controllers-vs-hooks.md
upstream-mirror 3a013ccdfe
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
snapshot of https://github.com/TanStack/query.git (main, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:13:08 +05:00

5.3 KiB

id, title
id title
reactive-controllers-vs-hooks 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:

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.

Reading Results

Lit Query controller creators return a callable accessor with a current property:

const query = this.todos()
const sameQuery = this.todos.current

Render 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>`
}

Reactive Options

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.

Provider Context

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.