Getting Started

Octane Quick Start

TanStack Pacer controls when your functions run. The Octane adapter, @tanstack/octane-pacer, wraps each Pacer utility in a hook. The hook keeps one utility instance across renders, cancels pending work when the component unmounts, and re-renders only for the state you select.

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

Installation

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

The adapter requires Octane 0.12 or newer, and Node.js 22.22.2 or newer when it runs in Node.js. It re-exports everything from @tanstack/pacer, so you do not need to install the core package. See Installation for other package managers.

Call Pacer hooks at the top level of a compiled Octane component. The Octane compiler gives each call its own hook slot, the same way it does for useState.

Your first debouncer

The component below is complete. The input updates on every keystroke. debouncedQuery updates 500 ms after the user stops typing.

tsx
import { useState } from 'octane'
import { useDebouncedValue } from '@tanstack/octane-pacer'

export function Search() {
  const [query, setQuery] = useState('')
  const [debouncedQuery] = useDebouncedValue(query, { wait: 500 })

  return (
    <div>
      <input
        value={query}
        onInput={(e) => setQuery(e.currentTarget.value)}
        placeholder="Search..."
      />
      <p>Searching for: {debouncedQuery}</p>
    </div>
  )
}
import { useState } from 'octane'
import { useDebouncedValue } from '@tanstack/octane-pacer'

export function Search() {
  const [query, setQuery] = useState('')
  const [debouncedQuery] = useDebouncedValue(query, { wait: 500 })

  return (
    <div>
      <input
        value={query}
        onInput={(e) => setQuery(e.currentTarget.value)}
        placeholder="Search..."
      />
      <p>Searching for: {debouncedQuery}</p>
    </div>
  )
}

Pass debouncedQuery to your data fetching instead of query, and a fast typist sends one request instead of one per keystroke.

Pick a hook

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

HookReturnsUse it when
useDebouncedCallbackA debounced functionYou only need to call the function
useDebouncedValue[debouncedValue, debouncer]You already have a value in state and want a lagging copy
useDebouncedState[value, setValue, debouncer]You want useState with a debounced setter
useDebouncerThe debouncer instanceYou need flush, cancel, or reactive state

The other utilities follow the same naming pattern:

UtilityInstance hookAsync instance hookGuide
DebouncinguseDebounceruseAsyncDebouncerDebouncing
ThrottlinguseThrottleruseAsyncThrottlerThrottling
Rate limitinguseRateLimiteruseAsyncRateLimiterRate Limiting
QueuinguseQueueruseAsyncQueuerQueuing
BatchinguseBatcheruseAsyncBatcherBatching

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

Common patterns

Control the debouncer directly

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

tsx
import { useState } from 'octane'
import { useDebouncer } from '@tanstack/octane-pacer'

export function DraftEditor() {
  const [draft, setDraft] = useState('')

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

  return (
    <div>
      <textarea
        value={draft}
        onInput={(e) => {
          setDraft(e.currentTarget.value)
          saver.maybeExecute(e.currentTarget.value)
        }}
      />
      <button onClick={() => saver.flush()}>Save now</button>
      <button onClick={() => saver.cancel()}>Discard</button>
      {saver.state.isPending && <p>Unsaved changes...</p>}
    </div>
  )
}
import { useState } from 'octane'
import { useDebouncer } from '@tanstack/octane-pacer'

export function DraftEditor() {
  const [draft, setDraft] = useState('')

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

  return (
    <div>
      <textarea
        value={draft}
        onInput={(e) => {
          setDraft(e.currentTarget.value)
          saver.maybeExecute(e.currentTarget.value)
        }}
      />
      <button onClick={() => saver.flush()}>Save now</button>
      <button onClick={() => saver.cancel()}>Discard</button>
      {saver.state.isPending && <p>Unsaved changes...</p>}
    </div>
  )
}

Select the state you render

The third argument is a selector. Without it, saver.state is {} and state changes never re-render the component. Select only the fields you render.

When a child needs the state, use the Subscribe component instead. It subscribes that subtree only, so the owning component does not re-render:

tsx
<saver.Subscribe selector={(state) => ({ isPending: state.isPending })}>
  {({ isPending }) => (isPending ? <span>Saving...</span> : null)}
</saver.Subscribe>
<saver.Subscribe selector={(state) => ({ isPending: state.isPending })}>
  {({ isPending }) => (isPending ? <span>Saving...</span> : null)}
</saver.Subscribe>

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

Change options between renders

The hook applies the current options after each render, so you can read props and state directly:

tsx
const [debouncedQuery] = useDebouncedValue(query, {
  wait: isSlowNetwork ? 1000 : 300,
  enabled: query.length > 2,
})
const [debouncedQuery] = useDebouncedValue(query, {
  wait: isSlowNetwork ? 1000 : 300,
  enabled: query.length > 2,
})

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 hook first creates the utility.

Run async work

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

tsx
import { useState } from 'octane'
import { useAsyncDebouncer } from '@tanstack/octane-pacer'

export function AsyncSearch() {
  const [results, setResults] = useState<Array<SearchResult>>([])

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

  return (
    <div>
      <input onInput={(e) => searcher.maybeExecute(e.currentTarget.value)} />
      {searcher.state.isExecuting && <p>Loading...</p>}
      <ul>
        {results.map((result) => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </div>
  )
}
import { useState } from 'octane'
import { useAsyncDebouncer } from '@tanstack/octane-pacer'

export function AsyncSearch() {
  const [results, setResults] = useState<Array<SearchResult>>([])

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

  return (
    <div>
      <input onInput={(e) => searcher.maybeExecute(e.currentTarget.value)} />
      {searcher.state.isExecuting && <p>Loading...</p>}
      <ul>
        {results.map((result) => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </div>
  )
}

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:

tsx
import { useRateLimiter } from '@tanstack/octane-pacer'

export function SendButton() {
  const limiter = useRateLimiter(
    (message: string) => sendMessage(message),
    {
      limit: 5,
      window: 60_000,
      onReject: (limiter) =>
        alert(`Slow down. Try again in ${limiter.getMsUntilNextWindow()} ms.`),
    },
    (state) => ({ rejectionCount: state.rejectionCount }),
  )

  return (
    <div>
      <button onClick={() => limiter.maybeExecute('Hello')}>Send</button>
      <p>Rejected: {limiter.state.rejectionCount}</p>
    </div>
  )
}
import { useRateLimiter } from '@tanstack/octane-pacer'

export function SendButton() {
  const limiter = useRateLimiter(
    (message: string) => sendMessage(message),
    {
      limit: 5,
      window: 60_000,
      onReject: (limiter) =>
        alert(`Slow down. Try again in ${limiter.getMsUntilNextWindow()} ms.`),
    },
    (state) => ({ rejectionCount: state.rejectionCount }),
  )

  return (
    <div>
      <button onClick={() => limiter.maybeExecute('Hello')}>Send</button>
      <p>Rejected: {limiter.state.rejectionCount}</p>
    </div>
  )
}

Process items in order

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

tsx
import { useAsyncQueuer } from '@tanstack/octane-pacer'

export function Uploader() {
  const queue = useAsyncQueuer(
    async (file: File) => uploadFile(file),
    { concurrency: 3 },
    (state) => ({ size: state.size, activeItems: state.activeItems }),
  )

  return (
    <div>
      <input
        type="file"
        multiple
        onChange={(e) => {
          for (const file of e.currentTarget.files ?? []) queue.addItem(file)
        }}
      />
      <p>
        Uploading {queue.state.activeItems.length}, waiting {queue.state.size}
      </p>
    </div>
  )
}
import { useAsyncQueuer } from '@tanstack/octane-pacer'

export function Uploader() {
  const queue = useAsyncQueuer(
    async (file: File) => uploadFile(file),
    { concurrency: 3 },
    (state) => ({ size: state.size, activeItems: state.activeItems }),
  )

  return (
    <div>
      <input
        type="file"
        multiple
        onChange={(e) => {
          for (const file of e.currentTarget.files ?? []) queue.addItem(file)
        }}
      />
      <p>
        Uploading {queue.state.activeItems.length}, waiting {queue.state.size}
      </p>
    </div>
  )
}

Callback-only hooks

When you only need the scheduled function, the callback hooks skip the instance:

tsx
import { useThrottledCallback } from '@tanstack/octane-pacer'

const onScroll = useThrottledCallback(() => savePosition(window.scrollY), {
  wait: 200,
})
import { useThrottledCallback } from '@tanstack/octane-pacer'

const onScroll = useThrottledCallback(() => savePosition(window.scrollY), {
  wait: 200,
})

Use the instance hook instead when you need flush, cancel, or state.

Set default options

PacerProvider sets default options for every Pacer hook in its subtree. Options passed to a hook override the defaults.

tsx
import { PacerProvider } from '@tanstack/octane-pacer'

export function Root() {
  return (
    <PacerProvider
      defaultOptions={{
        debouncer: { wait: 500 },
        asyncQueuer: { concurrency: 3 },
        rateLimiter: { limit: 5, window: 60_000 },
      }}
    >
      <App />
    </PacerProvider>
  )
}
import { PacerProvider } from '@tanstack/octane-pacer'

export function Root() {
  return (
    <PacerProvider
      defaultOptions={{
        debouncer: { wait: 500 },
        asyncQueuer: { concurrency: 3 },
        rateLimiter: { limit: 5, window: 60_000 },
      }}
    >
      <App />
    </PacerProvider>
  )
}

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

tsx
import { debouncerOptions, useDebouncer } from '@tanstack/octane-pacer'

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

const debouncer = useDebouncer(search, { ...searchOptions, key: 'search' })
import { debouncerOptions, useDebouncer } from '@tanstack/octane-pacer'

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

const debouncer = useDebouncer(search, { ...searchOptions, key: 'search' })

Control unmount cleanup

When the component unmounts, debouncers, throttlers, and batchers cancel pending work, and queuers stop processing. Async utilities also abort active work. To keep work instead, pass onUnmount. It replaces the default cleanup:

tsx
const saver = useDebouncer(saveDraft, {
  wait: 1000,
  onUnmount: (debouncer) => debouncer.flush(),
})
const saver = useDebouncer(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

Octane uses the framework-independent devtools. Mount TanStackDevtoolsCore with plugins: [pacerDevtoolsPlugin()] in a layout effect, and return its cleanup function. The Octane devtools setup has the full code and the Vite configuration it needs.

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

Next steps