Build a Reusable Angular “Search + Filters” Component (Inputs, Outputs, RxJS, and Reactive Forms)
Reusable components are one of the fastest ways to level up an Angular codebase—especially on teams where multiple screens need the same UI behavior (search, filters, debouncing, loading states, etc.). In this hands-on guide, you’ll build a reusable <app-search-bar> component that:
- Accepts configuration via
@Input()(placeholder text, available filters, initial values) - Emits structured changes via
@Output()(search term + selected filter) - Uses
ReactiveFormsfor clean state handling - Debounces typing with RxJS so you don’t spam API calls
- Integrates cleanly in a parent page that calls an HTTP API
This pattern (a “dumb” UI component + a “smart” page container) is friendly for junior/mid developers because it keeps responsibilities clear and makes testing easier.
1) Create a Demo App
If you want to follow along in a fresh project:
npm install -g @angular/cli ng new search-demo --routing --style=scss cd search-demo ng add @angular/material
(Material is optional, but it makes the UI nicer. The component below works with plain HTML too.)
Make sure Reactive Forms is available in your feature module or standalone component imports. In modern Angular, standalone components are common, so we’ll use that.
2) Define the Data Contract
Your component should emit a single event object that describes the current search “query”. This keeps parent components simple.
// src/app/shared/search-bar/search.models.ts export type SearchFilter = { label: string; value: string; }; export type SearchQuery = { term: string; filter: string; // value from SearchFilter };
3) Build the SearchBar Component
Create the component:
ng generate component shared/search-bar --standalone
Now implement it with inputs + outputs + debounced form changes.
// src/app/shared/search-bar/search-bar.component.ts import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; import { Subject, combineLatest } from 'rxjs'; import { debounceTime, distinctUntilChanged, map, startWith, takeUntil } from 'rxjs/operators'; import { SearchFilter, SearchQuery } from './search.models'; @Component({ selector: 'app-search-bar', standalone: true, imports: [CommonModule, ReactiveFormsModule], templateUrl: './search-bar.component.html', styleUrls: ['./search-bar.component.scss'], }) export class SearchBarComponent implements OnInit, OnDestroy { @Input() placeholder = 'Search...'; @Input() filters: SearchFilter[] = [ { label: 'All', value: 'all' }, ]; @Input() initialTerm = ''; @Input() initialFilter = 'all'; // Debounce typing (ms). Parent can override. @Input() debounceMs = 300; @Output() queryChange = new EventEmitter<SearchQuery>(); private destroy$ = new Subject<void>(); form = this.fb.group({ term: [''], filter: ['all'], }); constructor(private fb: FormBuilder) {} ngOnInit(): void { // Initialize form with inputs this.form.patchValue( { term: this.initialTerm, filter: this.initialFilter }, { emitEvent: false } ); // Stream term changes with debounce + distinct const term$ = this.form.controls.term.valueChanges.pipe( startWith(this.form.controls.term.value), map(v => (v ?? '').trim()), debounceTime(this.debounceMs), distinctUntilChanged() ); // Stream filter changes (usually no debounce needed) const filter$ = this.form.controls.filter.valueChanges.pipe( startWith(this.form.controls.filter.value), map(v => v ?? 'all'), distinctUntilChanged() ); // Combine and emit structured query combineLatest([term$, filter$]) .pipe(takeUntil(this.destroy$)) .subscribe(([term, filter]) => { this.queryChange.emit({ term, filter }); }); } clear(): void { this.form.patchValue({ term: '' }); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } }
Template:
<!-- src/app/shared/search-bar/search-bar.component.html --> <div class="search-bar"> <input type="text" class="search-input" [placeholder]="placeholder" [formControl]="form.controls.term" /> <select class="search-select" [formControl]="form.controls.filter"> <option *ngFor="let f of filters" [value]="f.value"> {{ f.label }} </option> </select> <button type="button" class="search-clear" (click)="clear()" [disabled]="!form.value.term"> Clear </button> </div>
Basic styles:
/* src/app/shared/search-bar/search-bar.component.scss */ .search-bar { display: flex; gap: 0.75rem; align-items: center; } .search-input { flex: 1; padding: 0.6rem 0.75rem; } .search-select { padding: 0.6rem 0.75rem; } .search-clear { padding: 0.6rem 0.75rem; }
4) Use It in a “Smart” Page Component
Now build a page that listens to (queryChange) and calls an API. We’ll implement a simple service that queries a backend endpoint like /api/users?term=...&filter=.... Even if you don’t have a backend yet, the structure is realistic.
Create a Users page:
ng generate component pages/users --standalone
Service:
// src/app/pages/users/users.service.ts import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; export type User = { id: number; name: string; email: string; status: 'active' | 'invited' | 'disabled' }; @Injectable({ providedIn: 'root' }) export class UsersService { constructor(private http: HttpClient) {} searchUsers(term: string, filter: string): Observable<User[]> { const params = new HttpParams() .set('term', term) .set('filter', filter); return this.http.get<User[]>('/api/users', { params }); } }
Users component (the “smart” container):
// src/app/pages/users/users.component.ts import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { HttpClientModule } from '@angular/common/http'; import { BehaviorSubject, switchMap, catchError, of, tap } from 'rxjs'; import { SearchBarComponent } from '../../shared/search-bar/search-bar.component'; import { SearchFilter, SearchQuery } from '../../shared/search-bar/search.models'; import { UsersService, User } from './users.service'; @Component({ selector: 'app-users', standalone: true, imports: [CommonModule, HttpClientModule, SearchBarComponent], templateUrl: './users.component.html', }) export class UsersComponent { filters: SearchFilter[] = [ { label: 'All', value: 'all' }, { label: 'Active', value: 'active' }, { label: 'Invited', value: 'invited' }, { label: 'Disabled', value: 'disabled' }, ]; private query$ = new BehaviorSubject<SearchQuery>({ term: '', filter: 'all' }); loading = false; error: string | null = null; users$ = this.query$.pipe( tap(() => { this.loading = true; this.error = null; }), switchMap(q => this.usersService.searchUsers(q.term, q.filter).pipe( catchError(err => { this.error = 'Failed to load users'; return of([] as User[]); }) ) ), tap(() => (this.loading = false)) ); constructor(private usersService: UsersService) {} onQueryChange(q: SearchQuery): void { this.query$.next(q); } }
Users template:
<!-- src/app/pages/users/users.component.html --> <h2>Users</h2> <app-search-bar placeholder="Search by name or email..." [filters]="filters" [debounceMs]="350" (queryChange)="onQueryChange($event)"> </app-search-bar> <p *ngIf="loading">Loading...</p> <p *ngIf="error">{{ error }}</p> <ul> <li *ngFor="let u of (users$ | async)"> <strong>{{ u.name }}</strong> <span> — {{ u.email }} ({{ u.status }})</span> </li> </ul>
5) Common Pitfalls (and How This Component Avoids Them)
-
Spamming the API on every keystroke: the debounced
term$stream usesdebounceTime+distinctUntilChangedso you only fire when the user pauses and the value actually changed. -
Messy event payloads: emitting a single
SearchQueryobject keeps the parent clean and reduces the “where did this value come from?” confusion. -
Subscriptions leaking memory: the component uses
takeUntil(this.destroy$)to automatically unsubscribe on destroy. -
Components doing too much: the search bar is purely UI + state. The parent decides what “search” means (HTTP, local filter, store dispatch, etc.).
6) Optional Enhancements You Can Add Next
If you want to take this from “useful” to “production-ready”, here are upgrades that fit the same pattern:
-
Add a minimum length: only emit API searches when
term.length >= 2, but still emit when clearing the term. -
Expose a “Search” button mode: some screens want explicit submit instead of instant search—make it a boolean input like
instant = true. -
Persist query to the URL: parent component can sync
SearchQuerywith query params so refresh/back works naturally. -
Support multi-select filters: swap
<select>for checkboxes and change your output type accordingly.
7) Quick Mental Model: “Dumb Component, Smart Container”
When you’re deciding what belongs in a reusable component, ask:
-
Does it render UI and manage local UI state? Put it in the component.
-
Does it call APIs, know routes, or handle business rules? Keep that in the page/container.
This separation makes your components portable across projects, reduces regression risk, and helps teammates quickly understand where to make changes.
With the <app-search-bar> built above, you now have a practical, reusable Angular building block you can drop into any list page—users, orders, logs, products—without rewriting debouncing and form wiring every time.
Leave a Reply