skip to content

In Angular 22, how does debounced() delay a filter signal before it feeds a resource(), and what does it report while waiting?

level: middleimportance: nice to knowfreq 20%

answer

  1. experimental in v22
  2. returns a read-only Resource
  3. timer restarts on change
  4. loading keeps the settled value
  5. feed value() into params

basics

~20 s

debounced(source, wait) returns a read-only Resource whose value() is the last settled value of the source signal; while the wait runs it reports 'loading' and keeps the old value, so a resource reading it in params only reloads once input settles.

solid answer

~40 s

`debounced()` is an experimental Angular 22 API in `@angular/core`. `debounced(this.filter, 300)` returns a read-only `Resource`: it starts `'resolved'` with the source's current value, and each change starts a 300 ms timer, during which `status()` is `'loading'` and `value()` still returns the previous settled value. A further change restarts the timer; when it expires, the status becomes `'resolved'` with the new value. Wire it in with `params: () => this.debouncedFilter.value()` — because the value only changes when the input settles, the order-history resource loads once per pause, not per keystroke. `wait` can also be a function returning a promise, `equal` defaults to `Object.is`, a throwing source moves it to `'error'` immediately, and it needs an injection context or an `injector`.

code

ts · 34 lines
ts
import { Component, debounced, input, resource, signal } from '@angular/core';

interface Order {
  id: string;
  product: string;
}

@Component({
  selector: 'app-order-search',
  template: `
    <input (input)="filter.set($any($event.target).value)" placeholder="Filter by product" />
    @if (debouncedFilter.isLoading()) {
      <small>Waiting for typing to stop...</small>
    }
    @if (orders.hasValue()) {
      @for (order of orders.value(); track order.id) {
        <p>{{ order.product }}</p>
      }
    }
  `,
})
export class OrderSearch {
  readonly userId = input.required<string>();
  readonly filter = signal('');
  readonly debouncedFilter = debounced(this.filter, 300);

  readonly orders = resource({
    params: () => ({ userId: this.userId(), q: this.debouncedFilter.value() }),
    loader: ({ params, abortSignal }) =>
      fetch(`/api/users/${params.userId}/orders?q=${encodeURIComponent(params.q)}`, {
        signal: abortSignal,
      }).then((res) => res.json() as Promise<Order[]>),
  });
}

go deeper

for a junior

Recall that debounced() wraps a signal and delays it, and that you read its value() in a resource's params.

for a middle

Explain the loading-while-waiting status, why value() keeps the settled value, and how that stops a load per keystroke.

for a senior

Weigh debounced() against an RxJS debounceTime pipeline or an effect with a timer, noting that it is experimental in Angular 22.

for a principal

Decide whether the team adopts an experimental API in production code, and how to contain it behind a helper if its API changes.

## The problem it solves An order-history screen has a text box that filters a customer's orders on the server by product name. The filter lives in a `signal()` updated on every `input` event, and the order `resource()` reads it in `params`. Without a delay, every keystroke produces new params, and every new params value starts a load — the previous one is aborted, but the server still sees a request per character. The fix is to wait until the user pauses. Angular 22 adds `debounced()` to `@angular/core` for exactly this, marked **experimental**: usable, but its API may still change. ## The signature `debounced(source, wait, options?)`: - **`source`** — a signal, or any function reading signals, whose value you want to delay. - **`wait`** — a number of milliseconds, or a function `(value, lastSnapshot) => Promise<void> | void` that decides the delay per value. - **`options`** — `injector` for use outside an injection context, and `equal`, a custom equality function (the default is `Object.is`). It returns a read-only **`Resource<T>`** — the same interface a `resource()` exposes, minus the writable parts, so there is no `set()` and no `reload()`. ## What it reports over time | Moment | `status()` | `value()` | | --- | --- | --- | | Created | `'resolved'` | The source's current value | | Source changed, timer running | `'loading'` | The previous settled value | | Source changed again before expiry | `'loading'` | Still the previous settled value; timer restarted | | Timer expired | `'resolved'` | The latest source value | | Source function threw | `'error'` | Reading `value()` throws | Three details follow from the implementation: 1. A change that is equal to the value already settled (or already pending) is ignored and starts no timer. 2. If the source throws, the resource enters `'error'` **immediately**; no timer runs. 3. When the injector is destroyed — the component leaves the page — the pending timer is cancelled. ## Wiring it to a resource ```ts readonly filter = signal(''); readonly debouncedFilter = debounced(this.filter, 300); readonly orders = resource({ params: () => ({ userId: this.userId(), q: this.debouncedFilter.value() }), loader: ({ params, abortSignal }) => this.api.searchOrders(params, abortSignal), }); ``` The key is reading **`value()`** in `params`. While the user types, `debouncedFilter` is `'loading'` but its value does not move, so `params` produces nothing new and the order resource does not reload. When the timer fires, the value changes once and one load starts. A small "typing…" hint can bind to `debouncedFilter.isLoading()` without touching the orders' own status. ## A custom wait function Passing a function instead of a number lets the delay depend on the value or the previous state. Returning nothing (`undefined`) settles immediately; returning a promise settles when it resolves, and a newer value discards the older promise. The guide's example waits longer for short queries and skips the wait entirely after an error so a retry feels instant. ## How it compares with the alternatives - **An RxJS `debounceTime` pipeline** through the interop functions works, but moves the value through an Observable and back just to add a delay. - **A `setTimeout` inside an `effect()`** writing to a second signal re-implements timer cancellation and teardown by hand. - **`debounced()`** keeps everything in signals, exposes the waiting state as a status, and cleans up with its injector — at the cost of being experimental in Angular 22. ## Things to remember - It is a `Resource`, so read `.value()`, not the call result itself. - It does not delay the first value: it starts resolved. - It debounces *reads*; the underlying `filter` signal still updates on every keystroke, which keeps the text box responsive.

  • Why read debouncedFilter.value() in params rather than debouncedFilter.status()?
    `params` should produce a new value only when the request should change. `value()` moves once per settled input, so each pause triggers one load. Reading `status()` would make params recompute on every loading/resolved flip, and the settled text is what the request actually needs.
  • Can you call set() or reload() on the result of debounced()?
    No. `debounced()` returns a read-only `Resource`, not a `ResourceRef`, so it has the status and value signals but no `set()`, `update()` or `reload()`. Its value only changes when the source signal settles.

saying these in an interview costs you the question

  • debounced() returns a signal you call directly to get the text.
  • value() is undefined while the debounce timer is running.
  • The first value is also delayed by the wait time.
  • debounced() is stable public API with no experimental marker.
  • Every keystroke still reaches the resource's params as a new value.