Aproape orice aplicație are o listă în care cauți, filtrezi, sortezi și treci de la o pagină la alta. Cu signals ajung câteva rânduri: filtrele sunt signal-uri, iar lista de pe ecran e un computed construit din ele. Partea interesantă e cum împarți acel computed.
Pagina asta construiește un catalog pas cu pas: un lanț de computed-uri care recalculează doar ce s-a schimbat, linkedSignal ca să resetezi pagina și filtrele ținute în URL, ca un link sau un reload să aducă înapoi aceeași vedere.
Starea: câteva signal-uri
Fiecare control scrie un signal: query, category, sort, page. Nu se mai păstrează nimic altceva — lista vizibilă e mereu derivată, deci nu se poate îndepărta niciodată de filtre.
export class Catalog {
private readonly products = inject(ProductStore).all; // Signal<Product[]>
// un signal per control
readonly query = signal('');
readonly category = signal<Category | 'all'>('all');
readonly sort = signal<Sort>('relevance');
readonly page = signal(1);
}Un lanț de computed-uri
Pune fiecare pas în propriul computed: filtered citește datele și filtrele, sorted citește filtered și ordinea, visible citește sorted și pagina. Un computed rulează din nou doar când s-a schimbat ceva din ce a citit, deci schimbarea paginii rulează doar tăierea listei.
// fiecare pas citește doar ce îi trebuie — și rulează din nou doar când asta se schimbă
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())), // o copie: nu sorta niciodată array-ul din signal
);
readonly visible = computed(() => {
const start = (this.page() - 1) * PAGE_SIZE;
return this.sorted().slice(start, start + PAGE_SIZE); // se schimbă pagina → rulează doar asta
});
readonly pages = computed(() => Math.max(1, Math.ceil(this.sorted().length / PAGE_SIZE)));| TelefonTelefoane și tablete | 351,99 EUR | ★ 2.6 |
| Telefon MiniTelefoane și tablete | 509,99 EUR | ★ 2.7 |
| Telefon AirTelefoane și tablete | 552,99 EUR | ★ 4.3 |
| Telefon ProTelefoane și tablete | 920,99 EUR | ★ 4.6 |
| Telefon MaxTelefoane și tablete | 778,99 EUR | ★ 4.8 |
| TabletăTelefoane și tablete | 377,99 EUR | ★ 3.9 |
| Pas | Rulări | Ultima rulare |
|---|---|---|
filtered | 1 | a verificat 65 produse |
sorted | 1 | 64 comparații |
visible | 1 | a luat 6 elemente |
În lanț, butoanele de pagină rulează doar visible; sortarea rulează sorted și visible; doar filtrele ajung la filtered. Într-un singur computed mare, fiecare click filtrează și sortează din nou toate cele 65 de produse. Contează și ordinea: întâi filtrezi (rămân mai puține de sortat), la final tai pagina. Și rulează doar forma de pe ecran: un computed pe care nu-l citește nimeni nu se execută niciodată. Dacă tastezi repede, poți vedea mai puține rulări decât taste: un computed se recalculează doar când e citit, iar Angular îl citește o dată pe randare.
Înapoi la pagina 1 când se schimbă filtrele
Dacă ești pe pagina 5 și cauți ceva cu două pagini de rezultate, ajungi pe „pagina 5 din 2”. linkedSignal e făcut exact pentru asta: un signal în care poți scrie și care se resetează singur ori de câte ori i se schimbă sursa — aici, orice filtru sau ordinea. Vezi linkedSignal.
// un signal în care poți scrie și care revine la 1 când se schimbă un filtru sau ordinea
readonly page = linkedSignal({
source: () => [this.query(), this.category(), this.sort()],
computation: () => 1,
});
next() {
this.page.update((p) => Math.min(p + 1, this.pages()));
}O căutare care se simte firesc
Compară text normalizat, cu litere mici și fără diacritice (normalize('NFD')), ca „casti” să găsească „Căști”. Sortează numele cu localeCompare în limba utilizatorului. Pentru căutări care merg la server, așteaptă o pauză în tastare cu debounced().
/** litere mici, fără diacritice: "casti" găsește "Căști", "cafe" găsește "Café" */
export function fold(text: string): string {
return text.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
}
// nume sortate cum se așteaptă utilizatorul în limba lui
const byName = (a: Product, b: Product) => a.name.localeCompare(b.name, locale);Randarea: track după id
@for (p of visible(); track p.id) îl lasă pe Angular să mute rândurile existente în loc să le recreeze când se schimbă ordinea. Cu track $index, fiecare rând ar fi randat din nou după fiecare sortare. Mai multe la 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>Niciun produs nu se potrivește.</p>
}
<button (click)="page.set(page() - 1)" [disabled]="page() === 1">Înapoi</button>
<span>Pagina {{ page() }} din {{ pages() }}</span>
<button (click)="page.set(page() + 1)" [disabled]="page() === pages()">Înainte</button>Filtrele în URL
Filtrele ținute doar în signal-uri dispar la reload și nu pot fi trimise nimănui. Pune-le în query string — /catalog?q=pro&cat=audio — și URL-ul devine starea: un link redeschide aceeași vedere, iar Back se întoarce la vederea anterioară în loc să plece de pe pagină.
65 rezultate · Telefon, Telefon Mini, Telefon Air
- /catalog
angularatlas.dev/catalog
Cine deschide linkul vede exact ce conține linkul — nimic în plus.
Doar cu signal-uri, Back pleacă din catalog, iar Reload îl golește. Dacă pui fiecare schimbare în istoric, problema dispare, dar Back devine un undo pentru fiecare tastă. Compromisul obișnuit: înlocuiești intrarea curentă din istoric cât timp utilizatorul tastează și adaugi una nouă pentru alegeri clare, cum e o categorie.
// app.config.ts: provideRouter(routes, withComponentInputBinding())
export class Catalog {
private readonly router = inject(Router);
// ?q=…&cat=… ajung ca input-uri; URL-ul e sursa de adevăr
readonly q = input('');
readonly cat = input<Category | 'all'>('all');
search(text: string) {
// tastare: înlocuiești intrarea curentă din istoric, nu adaugi câte una pe tastă
this.update({ q: text || null }, true);
}
pickCategory(cat: Category | 'all') {
this.update({ cat: cat === 'all' ? null : cat }, false); // un pas real: Back se întoarce aici
}
private update(queryParams: Params, replaceUrl: boolean) {
// null scoate un parametru: valorile implicite nu ajung în URL
this.router.navigate([], { queryParams, queryParamsHandling: 'merge', replaceUrl });
}
}Cu withComponentInputBinding(), parametrii din query ajung ca input-uri ale componentei; ca să-i scrii înapoi, faci un router.navigate() cu queryParamsHandling: 'merge'. Ține URL-ul scurt lăsând deoparte valorile implicite, cum face și laboratorul.
Când lista devine uriașă
Peste câteva mii de rânduri, randarea devine gâtul de sticlă, nu filtrarea: randează doar ce e pe ecran, cu virtual scroll. Când datele stau pe server, trimite filtrele ca parametri ai cererii și lasă httpResource să reîncarce când se schimbă.
Capcane frecvente
- Sortezi pe loc array-ul din signal.
list.sort()îl modifică. Fă întâi o copie ([...list]sautoSorted()), altfel următorul filtru lucrează pe o sursă reordonată. - Ții lista filtrată într-un signal. Atunci fiecare control trebuie să-și amintească s-o actualizeze. Deriv-o cu
computed. - „Pagina 5 din 2”. Pagina nu s-a resetat când s-au schimbat filtrele. Folosește
linkedSignal. - O intrare în istoric pentru fiecare tastă. Folosește
replaceUrl: truecât timp utilizatorul tastează. track $indexpe o listă sortabilă. Fiecare sortare randează din nou fiecare rând și pierde focusul și ce era scris în câmpuri.
Fișă rapidă
stare un signal per control: query, category, sort, page
derivat filtered → sorted → visible, fiecare cu propriul computed()
ordine întâi filtrezi, sortezi mai puține elemente, tai pagina la final
leneș un computed pe care nu-l citește nimeni nu rulează niciodată
resetarea paginii linkedSignal({ source: () => [filtre…], computation: () => 1 })
nu modifica toSorted() / [...list].sort(); array-ul din signal rămâne cum era
căutare normalize('NFD') + litere mici; localeCompare(b, locale) pentru nume
randare @for (… ; track p.id), niciodată track $index pe o listă sortabilă
URL ?q=&cat=&sort=; withComponentInputBinding(); fără valorile implicite
istoric replaceUrl cât tastezi, push pentru pașii reali (categorie, pagină)
liste uriașe virtual scroll pentru randare; httpResource când filtrează serverul