Angular Signals : Ajouter un debounce sur un input simplement
AngularDans cet article, je te montre comment retarder la mise à jour d’un champ de recherche en utilisant les Signals sans te perdre dans le code RxJS traditionnel.
➡️ Le résultat : du code plus simple, moderne et lisible.
Introduction
On veut créer un champ texte permettant de filtrer une liste de todos, mais en retardant sa mise à jour de 1 seconde après chaque frappe au clavier.
On va donc utiliser :
-
signal()
→ pour stocker la valeur saisie -
toObservable()
→ pour transformer le signal en flux RxJS -
debounceTime()
→ pour appliquer le délai -
toSignal()
→ pour revenir dans le monde des Signals
1. Définir le signal de recherche
On commence par créer un signal simple pour stocker la valeur du champ :
search = signal('');
Et on lie ce signal à un champ input dans le template :
<input
type="text"
name="search"
autocomplete="off"
placeholder="search a todo..."
[ngModel]="search()"
(ngModelChange)="search.set($event)"
/>
À chaque frappe, le signal search
est mis à jour instantanément.
2. Ajouter le debounce avec RxJS et toSignal()
Pour éviter de filtrer trop souvent, on va convertir notre search
signal en Observable, appliquer un debounce avec debounceTime()
, puis revenir à un signal.
debouncedSearch = toSignal(
toObservable(this.search).pipe(debounceTime(1000)),
{ initialValue: '' }
);
👉 Ici, le champ ne se mettra à jour qu’une seconde après la dernière frappe. Parfait pour ne pas déclencher le filtrage ou les requêtes réseau trop souvent.
3. Filtrer la liste avec un computed()
On veut maintenant filtrer notre tableau de todos en fonction du signal débouncé, pas du signal brut :
filteredTodos = computed(() => {
const term = this.debouncedSearch();
if (!term) {
return this.todos;
}
return this.todos.filter(todo =>
todo.name.toLowerCase().includes(term.toLowerCase())
);
});
Dès que debouncedSearch()
change (après le délai), Angular recalcule automatiquement la liste filtrée.
Code complet
Voici l’exemple complet pour référence :
import { Component, computed, signal } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import todos from './todo.json';
import { Todo } from './todo';
import { debounceTime } from 'rxjs/operators';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-root',
standalone: true,
imports: [FormsModule],
template: `
<h1>Filter Todos</h1>
<input type="text"
name="search"
autocomplete="off"
placeholder="search a todo..."
[ngModel]="search()"
(ngModelChange)="search.set($event)"
/>
<ul>
@for (todo of filteredTodos(); track todo) {
<li>{{ todo.name }}</li>
} @empty {
<li>There are no todo.</li>
}
</ul>
`,
})
export class App {
readonly todos = todos;
search = signal('');
debouncedSearch = toSignal(
toObservable(this.search).pipe(debounceTime(1000)),
{
initialValue: '',
}
);
filteredTodos = computed(() => {
const term = this.debouncedSearch();
if (!term) {
return this.todos;
}
return this.todos.filter((todo: Todo) =>
todo.name.toLowerCase().includes(term.toLowerCase())
);
});
}
bootstrapApplication(App);
Il aurait été possible d’arriver au même résultat en utilisant un effect()
, mais le code serait plus long et compliqué.