Guides & Concepts

Queries

New to Lit Query? Start with Installation and Quick Start 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 instead.

In Lit, subscribe to a query with createQueryController:

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>
    `
  }
}
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
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>`
}
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>
  `
}
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,
  }))
}
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>`
html`<button @click=${() => this.todos.refetch()}>Refetch</button>`

For multiple queries that should run at the same time, see Parallel Queries.