Presque toutes les applications ont une liste qu’on peut rechercher, filtrer, trier et parcourir page par page. Avec les signals, quelques lignes suffisent : les filtres sont des signals, et la liste à l’écran est un computed construit à partir d’eux. Le plus intéressant, c’est la façon de découper ce computed.
Cette page construit un catalogue pas à pas : une chaîne de signals computed qui ne recalcule que ce qui a changé, linkedSignal pour revenir à la première page, et des filtres gardés dans l’URL pour qu’un lien ou un rechargement ramène la même vue.
L’état : quelques signals
Chaque contrôle écrit un signal : query, category, sort, page. Rien d’autre n’est stocké — la liste visible est toujours dérivée, elle ne peut donc jamais s’écarter des filtres.
export class Catalog {
private readonly products = inject(ProductStore).all; // Signal<Product[]>
// un signal par contrôle
readonly query = signal('');
readonly category = signal<Category | 'all'>('all');
readonly sort = signal<Sort>('relevance');
readonly page = signal(1);
}Une chaîne de signals computed
Mettez chaque étape dans son propre computed : filtered lit les données et les filtres, sorted lit filtered et l’ordre de tri, visible lit sorted et la page. Un computed ne se réexécute que si quelque chose qu’il a lu a changé : changer de page ne relance que la découpe.
// chaque étape ne lit que ce dont elle a besoin — et ne se relance que si cela change
readonly filtered = computed(() => {
const q = fold(this.query().trim());
const cat = this.category();
return this.products().filter(
(p) => (cat === 'all' || p.category === cat) && fold(p.name).includes(q),
);
});
readonly sorted = computed(() =>
this.filtered().toSorted(comparator(this.sort())), // une copie : ne jamais trier le tableau du signal
);
readonly visible = computed(() => {
const start = (this.page() - 1) * PAGE_SIZE;
return this.sorted().slice(start, start + PAGE_SIZE); // la page change → seul ceci s’exécute
});
readonly pages = computed(() => Math.max(1, Math.ceil(this.sorted().length / PAGE_SIZE)));| TéléphoneTéléphones et tablettes | 351,99 € | ★ 2.6 |
| Téléphone MiniTéléphones et tablettes | 509,99 € | ★ 2.7 |
| Téléphone AirTéléphones et tablettes | 552,99 € | ★ 4.3 |
| Téléphone ProTéléphones et tablettes | 920,99 € | ★ 4.6 |
| Téléphone MaxTéléphones et tablettes | 778,99 € | ★ 4.8 |
| TabletteTéléphones et tablettes | 377,99 € | ★ 3.9 |
| Étape | Exécutions | Dernière exécution |
|---|---|---|
filtered | 1 | 65 produits vérifiés |
sorted | 1 | 64 comparaisons |
visible | 1 | 6 éléments pris |
Dans la chaîne, les boutons de page ne relancent que visible ; le tri relance sorted et visible ; seuls les filtres atteignent filtered. Dans un seul gros computed, chaque clic refiltre et retrie les 65 produits. L’ordre compte aussi : filtrer d’abord (moins d’éléments à trier), découper en dernier. Et seule la forme affichée s’exécute : un computed que personne ne lit ne s’exécute jamais. Si vous tapez vite, vous verrez peut-être moins d’exécutions que de frappes : un computed ne se recalcule que lorsqu’on le lit, et Angular le lit une fois par rendu.
Retour à la page 1 quand les filtres changent
Si vous êtes à la page 5 et cherchez quelque chose qui tient sur deux pages, vous vous retrouvez « page 5 sur 2 ». linkedSignal est fait pour ça : un signal modifiable qui se réinitialise chaque fois que sa source change — ici, n’importe quel filtre ou l’ordre de tri. Voir linkedSignal.
// un signal modifiable qui revient à 1 quand un filtre ou l’ordre de tri change
readonly page = linkedSignal({
source: () => [this.query(), this.category(), this.sort()],
computation: () => 1,
});
next() {
this.page.update((p) => Math.min(p + 1, this.pages()));
}Une recherche qui se comporte comme on l’attend
Comparez du texte normalisé, en minuscules et sans accents (normalize('NFD')), pour que « ecouteurs » trouve « Écouteurs ». Triez les noms avec localeCompare dans la langue de l’utilisateur. Pour les recherches qui partent vers un serveur, attendez une pause dans la frappe avec debounced().
/** minuscules, sans accents : "ecouteurs" trouve "Écouteurs", "cafe" trouve "Café" */
export function fold(text: string): string {
return text.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
}
// des noms triés comme l’utilisateur s’y attend dans sa langue
const byName = (a: Product, b: Product) => a.name.localeCompare(b.name, locale);Le rendu : track par id
@for (p of visible(); track p.id) permet à Angular de déplacer les lignes existantes au lieu de les recréer quand l’ordre change. Avec track $index, chaque ligne serait rendue à nouveau après chaque tri. Plus de détails dans control flow.
<input type="search" [value]="query()" (input)="query.set(search.value)" #search />
@for (p of visible(); track p.id) {
<app-product-row [product]="p" />
} @empty {
<p>Aucun produit ne correspond.</p>
}
<button (click)="page.set(page() - 1)" [disabled]="page() === 1">Précédent</button>
<span>Page {{ page() }} sur {{ pages() }}</span>
<button (click)="page.set(page() + 1)" [disabled]="page() === pages()">Suivant</button>Les filtres dans l’URL
Des filtres gardés seulement dans des signals disparaissent au rechargement et ne peuvent pas être partagés. Mettez-les dans la query string — /catalog?q=pro&cat=audio — et l’URL devient l’état : un lien rouvre la même vue, et Retour revient à la vue précédente au lieu de quitter la page.
65 résultats · Téléphone, Téléphone Mini, Téléphone Air
- /catalog
angularatlas.dev/catalog
Qui ouvre ce lien voit exactement ce que contient le lien — rien de plus.
Avec les seuls signals, Retour quitte le catalogue et Recharger le vide. Pousser chaque changement dans l’historique règle ce problème, mais transforme Retour en annulation de chaque frappe. Le compromis habituel : remplacer l’entrée courante de l’historique pendant que l’utilisateur tape, et en ajouter une nouvelle pour les choix nets, comme une catégorie.
// app.config.ts: provideRouter(routes, withComponentInputBinding())
export class Catalog {
private readonly router = inject(Router);
// ?q=…&cat=… arrivent comme inputs ; l’URL est la source de vérité
readonly q = input('');
readonly cat = input<Category | 'all'>('all');
search(text: string) {
// pendant la frappe : remplacer l’entrée courante, pas en ajouter une par touche
this.update({ q: text || null }, true);
}
pickCategory(cat: Category | 'all') {
this.update({ cat: cat === 'all' ? null : cat }, false); // une vraie étape : Retour revient ici
}
private update(queryParams: Params, replaceUrl: boolean) {
// null retire un paramètre : les valeurs par défaut restent hors de l’URL
this.router.navigate([], { queryParams, queryParamsHandling: 'merge', replaceUrl });
}
}Avec withComponentInputBinding(), les paramètres de la query string arrivent comme inputs du composant ; pour les réécrire, on fait un router.navigate() avec queryParamsHandling: 'merge'. Gardez l’URL courte en omettant les valeurs par défaut, comme le fait le labo.
Quand la liste devient énorme
Au-delà de quelques milliers de lignes, c’est le rendu qui coince, pas le filtrage : n’affichez que ce qui est à l’écran avec le virtual scroll. Quand les données sont sur le serveur, envoyez les filtres comme paramètres de requête et laissez httpResource recharger quand ils changent.
Pièges fréquents
- Trier sur place le tableau du signal.
list.sort()le modifie. Copiez d’abord ([...list]outoSorted()), sinon le filtre suivant travaille sur une source réordonnée. - Stocker la liste filtrée dans un signal. Chaque contrôle doit alors penser à la mettre à jour. Dérivez-la avec
computed. - « Page 5 sur 2 ». La page n’est pas revenue à 1 quand les filtres ont changé. Utilisez
linkedSignal. - Une entrée d’historique par frappe. Utilisez
replaceUrl: truependant que l’utilisateur tape. track $indexsur une liste triable. Chaque tri rend à nouveau toutes les lignes et perd le focus et la saisie en cours.
Aide-mémoire
état un signal par contrôle : query, category, sort, page
dérivé filtered → sorted → visible, chacun son computed()
ordre filtrer d’abord, trier moins d’éléments, découper en dernier
paresseux un computed que personne ne lit ne s’exécute jamais
revenir à la page 1 linkedSignal({ source: () => [filtres…], computation: () => 1 })
ne pas muter toSorted() / [...list].sort() ; le tableau du signal reste tel quel
recherche normalize('NFD') + minuscules ; localeCompare(b, locale) pour les noms
rendu @for (… ; track p.id), jamais track $index sur une liste triable
URL ?q=&cat=&sort= ; withComponentInputBinding() ; sans les valeurs par défaut
historique replaceUrl pendant la frappe, push pour les vraies étapes (catégorie, page)
listes énormes virtual scroll pour le rendu ; httpResource quand le serveur filtre