Visionner la vidéo

Angular Signals : Ajouter un debounce sur un input simplement

  Angular

Dans 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 :

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é.

Ressources

Code du projet avec Stackblitz

Commentaires