Interview question
Build a signal-driven filtered list with stable row identity
Builds writable and computed signal state for filtering and selection while preserving stable row identity and avoiding effect-driven derivation.
TL;DR
Builds writable and computed signal state for filtering and selection while preserving stable row identity and avoiding effect-driven derivation.
Angular signals, computed state, template control flow, stable identity, immutable updates, empty states, and component testing.
Practice the problem like a real interview: restate, reason, implement, and test.
Implement a standalone ProductList that receives products through a required signal input. Users can filter by text and select one row. Derive visible products and the selected product without copying them into effects. Use stable identity in the template, show an accessible empty state, and clear selection when the selected product no longer exists in the current input.
Products: [Keyboard, Mouse, Monitor]
Query: "mo" -> Mouse, Monitor
Selected: Mouse; parent removes Mouse -> selection becomes empty
Query: "zzz" -> "No products match your search"
computed.@for and track product.id, plus an @empty state.import {ChangeDetectionStrategy, Component, computed, input, signal} from '@angular/core';
type Product = {id: string; name: string};
@Component({
selector: 'app-product-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<label for="product-search">Filter products</label>
<input id="product-search"
[value]="query()"
(input)="query.set($any($event.target).value)" />
<ul>
@for (product of visibleProducts(); track product.id) {
<li>
<button type="button"
[attr.aria-pressed]="selectedId() === product.id"
(click)="selectedId.set(product.id)">
{{ product.name }}
</button>
</li>
} @empty {
<li>No products match your search.</li>
}
</ul>
`,
})
export class ProductList {
readonly products = input.required<readonly Product[]>();
readonly query = signal('');
readonly selectedId = signal<string | null>(null);
readonly visibleProducts = computed(() => {
const query = this.query().trim().toLocaleLowerCase();
return query
? this.products().filter(p => p.name.toLocaleLowerCase().includes(query))
: this.products();
});
readonly selectedProduct = computed(() =>
this.products().find(p => p.id === this.selectedId()) ?? null,
);
}
Each filter recomputation is O(n) over the products and selection lookup is O(n). Rendering reuses rows by stable ID, so unchanged products keep their DOM identity.
Move to the linked follow-up, next path step, prerequisite, or deeper variant.
Practice the next layer of the same subject.