Almost every app has a list you can search, filter, sort and page through. With signals it takes a few lines: the filters are signals, and the list on screen is a computed built from them. The interesting part is how you split that computed.
This page builds a catalog step by step: a chain of computed signals that recalculates only what changed, linkedSignal to reset the page, and filters kept in the URL so that a link or a reload brings back the same view.
The state: a few signals
Each control writes one signal: query, category, sort, page. Nothing else is stored — the visible list is always derived, so it can never drift away from the filters.
export class Catalog {
private readonly products = inject(ProductStore).all; // Signal<Product[]>
// one signal per control
readonly query = signal('');
readonly category = signal<Category | 'all'>('all');
readonly sort = signal<Sort>('relevance');
readonly page = signal(1);
}A chain of computed signals
Put each step in its own computed: filtered reads the data and the filters, sorted reads filtered and the sort order, visible reads sorted and the page. A computed runs again only when something it read has changed, so turning the page re-runs only the slice.
// each step reads only what it needs — and re-runs only when that changes
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())), // a copy: never sort the signal's array
);
readonly visible = computed(() => {
const start = (this.page() - 1) * PAGE_SIZE;
return this.sorted().slice(start, start + PAGE_SIZE); // the page changes → only this runs
});
readonly pages = computed(() => Math.max(1, Math.ceil(this.sorted().length / PAGE_SIZE)));| PhonePhones & tablets | €351.99 | ★ 2.6 |
| Phone MiniPhones & tablets | €509.99 | ★ 2.7 |
| Phone AirPhones & tablets | €552.99 | ★ 4.3 |
| Phone ProPhones & tablets | €920.99 | ★ 4.6 |
| Phone MaxPhones & tablets | €778.99 | ★ 4.8 |
| TabletPhones & tablets | €377.99 | ★ 3.9 |
| Step | Runs | Last run |
|---|---|---|
filtered | 1 | checked 65 products |
sorted | 1 | 64 comparisons |
visible | 1 | took 6 items |
In the chain, the page buttons re-run only visible; sorting re-runs sorted and visible; only the filters reach filtered. In one big computed, every click filters and sorts all 65 products again. Order matters too: filter first (fewer items to sort), slice last. And only the shape on screen runs at all: a computed that nobody reads never executes. Type quickly and you may see fewer runs than keystrokes: a computed recalculates only when it's read, and Angular reads it once per render.
Back to page 1 when the filters change
If you're on page 5 and search for something with two pages of results, you end up on “page 5 of 2”. linkedSignal is made for this: a writable signal that resets itself whenever its source changes — here, any filter or the sort order. See linkedSignal.
// a writable signal that goes back to 1 whenever a filter or the sort order changes
readonly page = linkedSignal({
source: () => [this.query(), this.category(), this.sort()],
computation: () => 1,
});
next() {
this.page.update((p) => Math.min(p + 1, this.pages()));
}Search that feels right
Compare normalized text, lowercase and without diacritics (normalize('NFD')), so that “cafe” finds “Café”. Sort names with localeCompare in the user's language. For searches that go to a server, wait for a pause in typing with debounced().
/** lowercase, without diacritics: "cafe" finds "Café", "casti" finds "Căști" */
export function fold(text: string): string {
return text.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
}
// names sorted the way the user expects in their language
const byName = (a: Product, b: Product) => a.name.localeCompare(b.name, locale);Rendering: track by id
@for (p of visible(); track p.id) lets Angular move existing rows instead of recreating them when the order changes. With track $index, every row would be re-rendered after each sort. More in 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>No products match.</p>
}
<button (click)="page.set(page() - 1)" [disabled]="page() === 1">Previous</button>
<span>Page {{ page() }} of {{ pages() }}</span>
<button (click)="page.set(page() + 1)" [disabled]="page() === pages()">Next</button>Filters in the URL
Filters held only in signals disappear on reload and can't be shared. Put them in the query string — /catalog?q=pro&cat=audio — and the URL becomes the state: a link reopens the same view, and Back returns to the previous view instead of leaving the page.
65 results · Phone, Phone Mini, Phone Air
- /catalog
angularatlas.dev/catalog
Whoever opens this link sees exactly what the link contains — nothing more.
With signals only, Back leaves the catalog and Reload empties it. Pushing every change into the history fixes that, but turns Back into an undo for every keystroke. The usual compromise: replace the current history entry while the user types, and push a new one for discrete choices such as a category.
// app.config.ts: provideRouter(routes, withComponentInputBinding())
export class Catalog {
private readonly router = inject(Router);
// ?q=…&cat=… arrive as inputs; the URL is the source of truth
readonly q = input('');
readonly cat = input<Category | 'all'>('all');
search(text: string) {
// typing: replace the current history entry, don't add one per key
this.update({ q: text || null }, true);
}
pickCategory(cat: Category | 'all') {
this.update({ cat: cat === 'all' ? null : cat }, false); // a real step: Back returns here
}
private update(queryParams: Params, replaceUrl: boolean) {
// null removes a parameter: default values stay out of the URL
this.router.navigate([], { queryParams, queryParamsHandling: 'merge', replaceUrl });
}
}With withComponentInputBinding(), query parameters arrive as component inputs; writing them back is a router.navigate() with queryParamsHandling: 'merge'. Keep the URL short by leaving out default values, as the lab does.
When the list gets huge
Past a few thousand rows, rendering becomes the bottleneck, not filtering: render only what's on screen with virtual scroll. When the data lives on the server, send the filters as request parameters and let httpResource reload when they change.
Common pitfalls
- Sorting the signal's array in place.
list.sort()mutates it. Copy first ([...list]ortoSorted()), or the next filter works on a reordered source. - Storing the filtered list in a signal. Then every control has to remember to update it. Derive it with
computed. - “Page 5 of 2”. The page didn't reset when the filters changed. Use
linkedSignal. - One history entry per keystroke. Use
replaceUrl: truewhile the user types. track $indexon a sortable list. Every sort re-renders every row and loses focus and input state.
Cheat sheet
state one signal per control: query, category, sort, page
derived filtered → sorted → visible, each its own computed()
order filter first, sort fewer items, slice last
lazy a computed nobody reads never runs
reset the page linkedSignal({ source: () => [filters…], computation: () => 1 })
never mutate toSorted() / [...list].sort(); the signal's array stays as it was
search normalize('NFD') + lowercase; localeCompare(b, locale) for names
rendering @for (… ; track p.id), never track $index on a sortable list
URL ?q=&cat=&sort=; withComponentInputBinding(); omit default values
history replaceUrl while typing, push for real steps (category, page)
huge lists virtual scroll for rendering; httpResource when the server filters