mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
docs: add debouncing section to signal forms async operations
Co-authored-by: Matthieu Riegler <kyro38@gmail.com>
Co-authored-by: Matthieu Riegler <kyro38@gmail.com>
Co-authored-by: Matthieu Riegler <kyro38@gmail.com>
(cherry picked from commit 18826de489)
This commit is contained in:
parent
b40b67cdc1
commit
1dde3827e9
1 changed files with 223 additions and 0 deletions
|
|
@ -350,6 +350,229 @@ export class Registration {
|
|||
|
||||
The `rxResource` function works directly with Observables and handles subscription cleanup automatically when the field value changes.
|
||||
|
||||
## Debouncing
|
||||
|
||||
The `debounce` rule delays when a user's input is committed to the form model. You can think of it as the rule holding back values until the user pauses typing. This is useful when downstream behavior shouldn't react to every keystroke, such as expensive derived computations, validation that flashes errors mid-word, or search filters that reapply on each character.
|
||||
|
||||
Add the `debounce` rule inside a schema to delay how a form field's UI changes reach the form model. In its simplest form, `debounce(path, ms)` holds each UI change for the given number of milliseconds before writing it to the model. A new change within that window resets the timer.
|
||||
|
||||
The following example applies `debounce` and `validateHttp` to the username field to delay the username availability check in a registration form until the user pauses typing:
|
||||
|
||||
```angular-ts
|
||||
import {Component, signal} from '@angular/core';
|
||||
import {form, debounce, validateHttp, FormField} from '@angular/forms/signals';
|
||||
|
||||
@Component({
|
||||
selector: 'app-registration',
|
||||
imports: [FormField],
|
||||
template: `
|
||||
<label>
|
||||
Username:
|
||||
<input [formField]="registrationForm.username" />
|
||||
</label>
|
||||
|
||||
@if (registrationForm.username().pending()) {
|
||||
<span class="checking">Checking availability...</span>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class Registration {
|
||||
registrationModel = signal({username: ''});
|
||||
|
||||
registrationForm = form(this.registrationModel, (schemaPath) => {
|
||||
// Hold UI updates for 300 ms before writing to the model
|
||||
debounce(schemaPath.username, 300);
|
||||
|
||||
// Runs against the debounced model value, not every keystroke
|
||||
validateHttp(schemaPath.username, {
|
||||
request: ({value}) => {
|
||||
const username = value();
|
||||
// Skip the request for blank values
|
||||
return username ? `/api/users/check?username=${username}` : undefined;
|
||||
},
|
||||
onSuccess: (response) =>
|
||||
response.available ? null : {kind: 'usernameTaken', message: 'Username is already taken'},
|
||||
onError: () => ({
|
||||
kind: 'serverError',
|
||||
message: 'Could not verify username availability',
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
With a 300 ms debounce, the model updates and validates only after the user pauses typing longer than the configured duration. For example, typing "signal forms" in a quick burst fires one validation request instead of twelve.
|
||||
|
||||
### Touch flushes the model
|
||||
|
||||
Regardless of the debounce duration, the framework writes the field's `controlValue()` to the model immediately when the field becomes touched. Native inputs become touched on blur, so a user who finishes typing and tabs away doesn't have to wait for the debounce timer to expire. Custom controls can mark the field as touched in response to any event they choose.
|
||||
|
||||
In the typical case, this matters for form submission. When the user clicks a submit button, the focused input blurs, which touches that field and flushes its pending debounce before the submission handler runs.
|
||||
|
||||
### Commit only on blur
|
||||
|
||||
Some fields shouldn't update mid-typing at all, and instead should only update after the user has finished entering a value. For example, if you have a search filter that reapplies on every change or a form that triggers expensive derived state, it is often better for the model to wait until the user finishes typing.
|
||||
|
||||
In these scenarios, pass `'blur'` instead of a duration to defer all updates until the field becomes touched:
|
||||
|
||||
```ts
|
||||
form(this.registrationModel, (schemaPath) => {
|
||||
debounce(schemaPath.username, 'blur');
|
||||
});
|
||||
```
|
||||
|
||||
With `'blur'`, the model keeps its previous value while the user is typing. Sync and async validation, derived signals, and any reactive rules reading the field all see the previous value until the field becomes touched. This commonly occurs when the user blurs a native input, or when a custom control signals touch on its own.
|
||||
|
||||
### Custom timing logic
|
||||
|
||||
For timing logic that a duration or `'blur'` can't express, pass a `Debouncer` function. The function receives the field context and an [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal), and returns a `Promise<void>` that resolves when the model should update:
|
||||
|
||||
```ts
|
||||
import {debounce, type Debouncer} from '@angular/forms/signals';
|
||||
|
||||
const shorterWhenLonger: Debouncer<string> = ({value}, abortSignal) => {
|
||||
// Shorter queries get a longer delay since the user is likely still typing.
|
||||
const ms = value().length < 3 ? 500 : 200;
|
||||
return new Promise((resolve) => {
|
||||
const timeoutId = setTimeout(resolve, ms);
|
||||
// Abort fires when this field is touched or its value changes, so the pending timer is cleared
|
||||
abortSignal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
},
|
||||
{once: true},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
form(this.registrationModel, (schemaPath) => {
|
||||
debounce(schemaPath.username, shorterWhenLonger);
|
||||
});
|
||||
```
|
||||
|
||||
The `abortSignal` fires when the field is touched, or when its value changes before the debounce resolves. Resolve the promise on abort so your debouncer releases any pending timers. The framework writes the pending value to the model on touch, and discards it when a newer value arrives. See the [`debounce` API reference](api/forms/signals/debounce) for the full `Debouncer` signature.
|
||||
|
||||
### Debouncing a single async validator
|
||||
|
||||
The `debounce` rule holds back every reaction to the field, from sync validation to derived signals to async validation. However, there are times when you want the opposite: cheap sync validators like `required` or `email` running immediately for instant feedback, while only the expensive async call waits for the user to settle. Both `validateHttp()` and `validateAsync()` accept their own [`debounce` option](api/forms/signals/validateAsync) that throttles just that validator:
|
||||
|
||||
```ts
|
||||
form(this.registrationModel, (schemaPath) => {
|
||||
validateHttp(schemaPath.username, {
|
||||
// Throttles only this HTTP call
|
||||
debounce: 300,
|
||||
request: ({value}) => {
|
||||
const username = value();
|
||||
// Skip the request for blank values
|
||||
return username ? `/api/users/check?username=${username}` : undefined;
|
||||
},
|
||||
onSuccess: (response) =>
|
||||
response.available ? null : {kind: 'usernameTaken', message: 'Username is already taken'},
|
||||
onError: () => ({
|
||||
kind: 'serverError',
|
||||
message: 'Could not verify username availability',
|
||||
}),
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
The model still updates on every keystroke, and any other rules attached to the field still react immediately. Only the HTTP request is debounced: each change waits 300 ms of quiet before firing, so a request only goes out once the user has paused typing.
|
||||
|
||||
Choose between the two layers based on scope:
|
||||
|
||||
| Option | When to use |
|
||||
| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `debounce()` rule | Sync validation, derived state, and submission should all wait until the field commits. The whole field shouldn't react mid-typing. |
|
||||
| `validateHttp({ debounce })` or `validateAsync({ debounce })` | Cheap sync validators should give immediate feedback, but expensive async calls should wait for the user to pause. |
|
||||
|
||||
Both options accept a duration in milliseconds. Their custom-timing callbacks differ: the form-level rule takes a `Debouncer`, and the validator-level option takes a `DebounceTimer` from `@angular/core`. The two signatures are not interchangeable.
|
||||
|
||||
## Composing resources in async validation with a factory
|
||||
|
||||
The built-in [`debounce` option](api/forms/signals/validateAsync) covers throttling, but `validateAsync()` exposes a deeper composition point: the `factory` function. The factory receives the params as a signal and returns a resource. Between those two points, you're free to compose whatever you need.
|
||||
|
||||
In its simplest form, a factory wraps a single resource. A username-availability check can live as a method on the component class, and then be wired into `validateAsync` by reference:
|
||||
|
||||
```ts
|
||||
export class Registration {
|
||||
registrationModel = signal({username: ''});
|
||||
private usernameValidator = inject(UsernameValidator);
|
||||
|
||||
// Factory function
|
||||
checkUsernameAvailable = (username: Signal<string | undefined>) =>
|
||||
resource({
|
||||
params: () => username(),
|
||||
loader: async ({params: name}) => this.usernameValidator.checkAvailability(name),
|
||||
});
|
||||
|
||||
registrationForm = form(this.registrationModel, (schemaPath) => {
|
||||
validateAsync(schemaPath.username, {
|
||||
params: ({value}) => {
|
||||
const username = value();
|
||||
// Skip validation for short usernames
|
||||
return username.length >= 3 ? username : undefined;
|
||||
},
|
||||
debounce: 300,
|
||||
// Reference to the factory defined above
|
||||
factory: this.checkUsernameAvailable,
|
||||
onSuccess: (result) =>
|
||||
result?.available ? null : {kind: 'usernameTaken', message: 'Username taken'},
|
||||
onError: () => ({kind: 'serverError', message: 'Could not verify'}),
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The `params` callback returns `undefined` for short usernames, signaling that validation should skip. With `debounce: 300` applied, the resource waits until the user pauses typing for 300 ms before acting on each change. It then runs the loader for valid usernames and stays idle once the debounced value settles to `undefined`.
|
||||
|
||||
### Combining debounce with additional logic
|
||||
|
||||
When you need logic beyond a plain duration debounce, use a custom factory to combine debouncing with that logic. A common case is caching validated responses. For example, once the server has confirmed a username, you don't need to ask again on subsequent keystrokes that revisit the same value.
|
||||
|
||||
```ts
|
||||
export class Registration {
|
||||
registrationModel = signal({username: ''});
|
||||
private usernameValidator = inject(UsernameValidator);
|
||||
|
||||
registrationForm = form(this.registrationModel, (schemaPath) => {
|
||||
validateAsync(schemaPath.username, {
|
||||
params: ({value}) => {
|
||||
const username = value();
|
||||
return username.length >= 3 ? username : undefined;
|
||||
},
|
||||
factory: (username) => {
|
||||
// Core primitive: settles 300 ms after the source stops changing
|
||||
const debouncedUsername = debounced(username, 300);
|
||||
// Cache lives in the factory's closure and persists for the field's lifetime
|
||||
const cache = new Map<string, {available: boolean}>();
|
||||
return resource({
|
||||
// Read from the debounced signal, not the raw one
|
||||
params: () => debouncedUsername.value(),
|
||||
loader: async ({params: name}) => {
|
||||
const cached = cache.get(name);
|
||||
if (cached) return cached;
|
||||
|
||||
const result = await this.usernameValidator.checkAvailability(name);
|
||||
cache.set(name, result);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: (result) =>
|
||||
result?.available ? null : {kind: 'usernameTaken', message: 'Username taken'},
|
||||
onError: () => ({
|
||||
kind: 'serverError',
|
||||
message: 'Could not verify username',
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The `cache` lives in the factory's closure, so it persists for the field's lifetime. Once the user has typed a username the server has already checked, the loader reads from the cache instead of making a new network request.
|
||||
|
||||
## Understanding pending state
|
||||
|
||||
When async validation runs, the field's `pending()` signal returns `true`. During this time:
|
||||
|
|
|
|||
Loading…
Reference in a new issue