Guides & Concepts

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 QueryLit 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 objectCallable result accessor
React context providerQueryClientProvider custom element
Component render rerunhost.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,
  })
}
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:

ts
const query = this.todos()
const sameQuery = this.todos.current
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>`
}
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),
  }))
}
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.

ts
customElements.define('query-client-provider', QueryClientProvider)
customElements.define('query-client-provider', QueryClientProvider)
ts
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:

  • 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.