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.
npm install @tanstack/lit-pacernpm install @tanstack/lit-pacerThe 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.
The element below is complete. The input updates on every keystroke. The debounced query updates 500 ms after the user stops typing.
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.
Every utility comes in several shapes. They share one engine and differ in what they hand back to you. For debouncing:
| Function | Returns | Use 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 |
| createDebouncer | The debouncer instance | You need maybeExecute, flush, cancel, or reactive state |
The other utilities follow the same naming pattern:
| Utility | Instance function | Async instance function | Guide |
|---|---|---|---|
| Debouncing | createDebouncer | createAsyncDebouncer | Debouncing |
| Throttling | createThrottler | createAsyncThrottler | Throttling |
| Rate limiting | createRateLimiter | createAsyncRateLimiter | Rate Limiting |
| Queuing | createQueuer | createAsyncQueuer | Queuing |
| Batching | createBatcher | createAsyncBatcher | Batching |
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?.
createDebouncer returns the instance. Call maybeExecute from your event handler, and use flush or cancel when the user acts before the timer fires.
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)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.
const status = saver.subscribe(childElement, (state) => ({
isPending: state.isPending,
}))
// In the child's render method
status().isPendingconst status = saver.subscribe(childElement, (state) => ({
isPending: state.isPending,
}))
// In the child's render method
status().isPendingEach utility's guide lists the state fields it exposes.
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:
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.
The async functions await your function, track execution state, and report errors. This search debounces the request and shows a loading state:
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.
A rate limiter allows a fixed number of calls per window and rejects the rest:
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)A queuer keeps every item and processes them in order. With createAsyncQueuer, concurrency sets how many run at once:
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)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.
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:
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' })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:
saver = createDebouncer(this, saveDraft, {
wait: 1000,
onUnmount: (debouncer) => debouncer.flush(),
})saver = createDebouncer(this, saveDraft, {
wait: 1000,
onUnmount: (debouncer) => debouncer.flush(),
})Install the devtools packages:
npm install @tanstack/devtools @tanstack/pacer-devtoolsnpm install @tanstack/devtools @tanstack/pacer-devtoolsLit 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.