Getting Started

Lit Quick Start

TanStack Pacer controls when your functions run. The Lit adapter, @tanstack/lit-pacer, wraps each Pacer utility in a create* function that takes your element as its first argument. The function registers a reactive controller on the element. The controller re-renders the element when selected state changes and cleans up pending work when the element disconnects.

This page starts with a debounced search input, then covers the patterns most apps need next.

Installation

sh
npm install @tanstack/lit-pacer
npm install @tanstack/lit-pacer

The adapter re-exports everything from @tanstack/pacer, so you do not need to install the core package. See Installation for other package managers.

The examples on this page declare reactive properties with static properties and class field initializers. For Lit to install its property accessors, set "useDefineForClassFields": false in your tsconfig.json.

Your first debouncer

The element below is complete. The input updates on every keystroke. The debounced query updates 500 ms after the user stops typing.

ts
import { LitElement, html } from 'lit'
import { createDebouncedValue } from '@tanstack/lit-pacer'

class SearchBox extends LitElement {
  static properties = { query: { state: true } }
  query = ''

  debounced = createDebouncedValue(this, () => this.query, { wait: 500 })

  override render() {
    const [debouncedQuery] = this.debounced
    return html`
      <input
        .value=${this.query}
        @input=${(e: Event) => {
          this.query = (e.target as HTMLInputElement).value
        }}
        placeholder="Search..."
      />
      <p>Searching for: ${debouncedQuery()}</p>
    `
  }
}

customElements.define('search-box', SearchBox)
import { LitElement, html } from 'lit'
import { createDebouncedValue } from '@tanstack/lit-pacer'

class SearchBox extends LitElement {
  static properties = { query: { state: true } }
  query = ''

  debounced = createDebouncedValue(this, () => this.query, { wait: 500 })

  override render() {
    const [debouncedQuery] = this.debounced
    return html`
      <input
        .value=${this.query}
        @input=${(e: Event) => {
          this.query = (e.target as HTMLInputElement).value
        }}
        placeholder="Search..."
      />
      <p>Searching for: ${debouncedQuery()}</p>
    `
  }
}

customElements.define('search-box', SearchBox)

createDebouncedValue takes a getter, () => this.query, and reads it each time the element updates. It returns [debouncedValue, debouncer], where debouncedValue is a getter. Pass the debounced value to your data fetching instead of query, and a fast typist sends one request instead of one per keystroke.

Pick a function

Every utility comes in several shapes. They share one engine and differ in what they hand back to you. For debouncing:

FunctionReturnsUse it when
createDebouncedValue[debouncedValue, debouncer]You already have a property and want a lagging copy
createDebouncedState[value, setValue, debouncer]You want a value with a debounced setter
createDebouncerThe debouncer instanceYou need maybeExecute, flush, cancel, or reactive state

The other utilities follow the same naming pattern:

UtilityInstance functionAsync instance functionGuide
DebouncingcreateDebouncercreateAsyncDebouncerDebouncing
ThrottlingcreateThrottlercreateAsyncThrottlerThrottling
Rate limitingcreateRateLimitercreateAsyncRateLimiterRate Limiting
QueuingcreateQueuercreateAsyncQueuerQueuing
BatchingcreateBatchercreateAsyncBatcherBatching

Each instance function also has a controller class, such as DebouncerController, if you prefer to construct controllers yourself. The controller exposes the utility as .pacer and the selected state as .state.

Not sure which utility you need? Read Which Pacer Utility Should I Choose?.

Common patterns

Control the debouncer directly

createDebouncer returns the instance. Call maybeExecute from your event handler, and use flush or cancel when the user acts before the timer fires.

ts
import { LitElement, html } from 'lit'
import { createDebouncer } from '@tanstack/lit-pacer'

class DraftEditor extends LitElement {
  static properties = { draft: { state: true } }
  draft = ''

  saver = createDebouncer(
    this,
    (text: string) => saveDraft(text),
    { wait: 1000 },
    (state) => ({ isPending: state.isPending }),
  )

  onInput = (e: Event) => {
    this.draft = (e.target as HTMLTextAreaElement).value
    this.saver.maybeExecute(this.draft)
  }

  override render() {
    return html`
      <textarea .value=${this.draft} @input=${this.onInput}></textarea>
      <button @click=${() => this.saver.flush()}>Save now</button>
      <button @click=${() => this.saver.cancel()}>Discard</button>
      ${this.saver.state.isPending ? html`<p>Unsaved changes...</p>` : null}
    `
  }
}

customElements.define('draft-editor', DraftEditor)
import { LitElement, html } from 'lit'
import { createDebouncer } from '@tanstack/lit-pacer'

class DraftEditor extends LitElement {
  static properties = { draft: { state: true } }
  draft = ''

  saver = createDebouncer(
    this,
    (text: string) => saveDraft(text),
    { wait: 1000 },
    (state) => ({ isPending: state.isPending }),
  )

  onInput = (e: Event) => {
    this.draft = (e.target as HTMLTextAreaElement).value
    this.saver.maybeExecute(this.draft)
  }

  override render() {
    return html`
      <textarea .value=${this.draft} @input=${this.onInput}></textarea>
      <button @click=${() => this.saver.flush()}>Save now</button>
      <button @click=${() => this.saver.cancel()}>Discard</button>
      ${this.saver.state.isPending ? html`<p>Unsaved changes...</p>` : null}
    `
  }
}

customElements.define('draft-editor', DraftEditor)

Select the state you render

The last argument is a selector. Read the selected fields from this.saver.state. Without a selector, it is {} and never changes. Select only the fields you render, because each change to the selection requests an update of the element.

A child element can subscribe to the same utility with its own selector. Call subscribe once, passing the child element. It returns a getter for the selection, and only the child updates when the selection changes. The subscription is released when the child disconnects and restored when it reconnects.

ts
const status = saver.subscribe(childElement, (state) => ({
  isPending: state.isPending,
}))

// In the child's render method
status().isPending
const status = saver.subscribe(childElement, (state) => ({
  isPending: state.isPending,
}))

// In the child's render method
status().isPending

Each utility's guide lists the state fields it exposes.

Make options reactive

To make an option follow a reactive property, pass a factory that returns the options. The controller calls it before each update of the element:

ts
class SearchBox extends LitElement {
  static properties = { wait: { type: Number } }
  wait = 300

  searcher = createDebouncer(this, search, () => ({ wait: this.wait }))
}
class SearchBox extends LitElement {
  static properties = { wait: { type: Number } }
  wait = 300

  searcher = createDebouncer(this, search, () => ({ wait: this.wait }))
}

Property getters such as get wait() { return this.wait } work too. A plain object such as { wait: this.wait } reads the property once.

When an option changes, the adapter updates the same utility. Pending work and state survive. A changed wait applies to the next call. It does not reschedule a timer that is already running. Setting enabled to false cancels pending work. key, initialState, and initialItems apply only when the utility is created.

Run async work

The async functions await your function, track execution state, and report errors. This search debounces the request and shows a loading state:

ts
import { LitElement, html } from 'lit'
import { createAsyncDebouncer } from '@tanstack/lit-pacer'

class AsyncSearch extends LitElement {
  static properties = { results: { state: true } }
  results: Array<SearchResult> = []

  searcher = createAsyncDebouncer(
    this,
    async (term: string) => {
      const data = await fetchSearchResults(term)
      this.results = data
      return data
    },
    {
      wait: 300,
      onError: (error) => console.error('Search failed:', error),
    },
    (state) => ({ isExecuting: state.isExecuting }),
  )

  override render() {
    return html`
      <input
        @input=${(e: Event) =>
          this.searcher.maybeExecute((e.target as HTMLInputElement).value)}
      />
      ${this.searcher.state.isExecuting ? html`<p>Loading...</p>` : null}
      <ul>
        ${this.results.map((result) => html`<li>${result.title}</li>`)}
      </ul>
    `
  }
}

customElements.define('async-search', AsyncSearch)
import { LitElement, html } from 'lit'
import { createAsyncDebouncer } from '@tanstack/lit-pacer'

class AsyncSearch extends LitElement {
  static properties = { results: { state: true } }
  results: Array<SearchResult> = []

  searcher = createAsyncDebouncer(
    this,
    async (term: string) => {
      const data = await fetchSearchResults(term)
      this.results = data
      return data
    },
    {
      wait: 300,
      onError: (error) => console.error('Search failed:', error),
    },
    (state) => ({ isExecuting: state.isExecuting }),
  )

  override render() {
    return html`
      <input
        @input=${(e: Event) =>
          this.searcher.maybeExecute((e.target as HTMLInputElement).value)}
      />
      ${this.searcher.state.isExecuting ? html`<p>Loading...</p>` : null}
      <ul>
        ${this.results.map((result) => html`<li>${result.title}</li>`)}
      </ul>
    `
  }
}

customElements.define('async-search', AsyncSearch)

maybeExecute returns a promise that resolves with your function's result. The async utilities also support retries and cancellation through an AbortSignal. See the Async Debouncing Guide.

Limit how often an action runs

A rate limiter allows a fixed number of calls per window and rejects the rest:

ts
import { LitElement, html } from 'lit'
import { createRateLimiter } from '@tanstack/lit-pacer'

class SendButton extends LitElement {
  limiter = createRateLimiter(
    this,
    (message: string) => sendMessage(message),
    {
      limit: 5,
      window: 60_000,
      onReject: (limiter) =>
        alert(`Slow down. Try again in ${limiter.getMsUntilNextWindow()} ms.`),
    },
    (state) => ({ rejectionCount: state.rejectionCount }),
  )

  override render() {
    return html`
      <button @click=${() => this.limiter.maybeExecute('Hello')}>Send</button>
      <p>Rejected: ${this.limiter.state.rejectionCount}</p>
    `
  }
}

customElements.define('send-button', SendButton)
import { LitElement, html } from 'lit'
import { createRateLimiter } from '@tanstack/lit-pacer'

class SendButton extends LitElement {
  limiter = createRateLimiter(
    this,
    (message: string) => sendMessage(message),
    {
      limit: 5,
      window: 60_000,
      onReject: (limiter) =>
        alert(`Slow down. Try again in ${limiter.getMsUntilNextWindow()} ms.`),
    },
    (state) => ({ rejectionCount: state.rejectionCount }),
  )

  override render() {
    return html`
      <button @click=${() => this.limiter.maybeExecute('Hello')}>Send</button>
      <p>Rejected: ${this.limiter.state.rejectionCount}</p>
    `
  }
}

customElements.define('send-button', SendButton)

Process items in order

A queuer keeps every item and processes them in order. With createAsyncQueuer, concurrency sets how many run at once:

ts
import { LitElement, html } from 'lit'
import { createAsyncQueuer } from '@tanstack/lit-pacer'

class Uploader extends LitElement {
  queue = createAsyncQueuer(
    this,
    async (file: File) => uploadFile(file),
    { concurrency: 3 },
    (state) => ({ size: state.size, activeItems: state.activeItems }),
  )

  onFiles = (e: Event) => {
    const files = (e.target as HTMLInputElement).files
    for (const file of files ?? []) this.queue.addItem(file)
  }

  override render() {
    const { activeItems, size } = this.queue.state
    return html`
      <input type="file" multiple @change=${this.onFiles} />
      <p>Uploading ${activeItems.length}, waiting ${size}</p>
    `
  }
}

customElements.define('file-uploader', Uploader)
import { LitElement, html } from 'lit'
import { createAsyncQueuer } from '@tanstack/lit-pacer'

class Uploader extends LitElement {
  queue = createAsyncQueuer(
    this,
    async (file: File) => uploadFile(file),
    { concurrency: 3 },
    (state) => ({ size: state.size, activeItems: state.activeItems }),
  )

  onFiles = (e: Event) => {
    const files = (e.target as HTMLInputElement).files
    for (const file of files ?? []) this.queue.addItem(file)
  }

  override render() {
    const { activeItems, size } = this.queue.state
    return html`
      <input type="file" multiple @change=${this.onFiles} />
      <p>Uploading ${activeItems.length}, waiting ${size}</p>
    `
  }
}

customElements.define('file-uploader', Uploader)

Set default options

Call providePacerOptions(host, defaults) in an element's constructor or field initializer. The defaults apply to Pacer utilities on that element and on its descendant elements, including across shadow roots. The nearest provider wins, and options passed to a utility override the defaults.

ts
import { LitElement, html } from 'lit'
import { providePacerOptions } from '@tanstack/lit-pacer'

class AppRoot extends LitElement {
  constructor() {
    super()
    providePacerOptions(this, {
      debouncer: { wait: 500 },
      asyncQueuer: { concurrency: 3 },
      rateLimiter: { limit: 5, window: 60_000 },
    })
  }

  override render() {
    return html`<slot></slot>`
  }
}
import { LitElement, html } from 'lit'
import { providePacerOptions } from '@tanstack/lit-pacer'

class AppRoot extends LitElement {
  constructor() {
    super()
    providePacerOptions(this, {
      debouncer: { wait: 500 },
      asyncQueuer: { concurrency: 3 },
      rateLimiter: { limit: 5, window: 60_000 },
    })
  }

  override render() {
    return html`<slot></slot>`
  }
}

To read reactive properties of the provider element, pass a factory instead of an object. Descendants receive the new defaults when the provider updates.

To share options between specific utilities instead, define them once with an option helper. Helpers such as debouncerOptions return the object you pass in, typed for that utility:

ts
import { createDebouncer, debouncerOptions } from '@tanstack/lit-pacer'

const searchOptions = debouncerOptions({ wait: 500, leading: false })

// In an element class
searcher = createDebouncer(this, search, { ...searchOptions, key: 'search' })
import { createDebouncer, debouncerOptions } from '@tanstack/lit-pacer'

const searchOptions = debouncerOptions({ wait: 500, leading: false })

// In an element class
searcher = createDebouncer(this, search, { ...searchOptions, key: 'search' })

Control cleanup

When the element disconnects, debouncers, throttlers, and batchers cancel pending work, and queuers stop processing. Async utilities also abort active work. If the element reconnects, the same utility subscribes again.

To keep work instead, pass onUnmount. It replaces the default cleanup:

ts
saver = createDebouncer(this, saveDraft, {
  wait: 1000,
  onUnmount: (debouncer) => debouncer.flush(),
})
saver = createDebouncer(this, saveDraft, {
  wait: 1000,
  onUnmount: (debouncer) => debouncer.flush(),
})

Set up devtools

Install the devtools packages:

sh
npm install @tanstack/devtools @tanstack/pacer-devtools
npm install @tanstack/devtools @tanstack/pacer-devtools

Lit uses the framework-independent devtools. Mount TanStackDevtoolsCore with plugins: [pacerDevtoolsPlugin()] once in your application, and unmount it when your root element disconnects. The Lit devtools setup has the full mount and cleanup code.

A utility appears in the Pacer panel only when you give it a key option.

Next steps