Build a Reusable Angular “Search + Filters” Component (Inputs, Outputs, RxJS, and Reactive Forms)

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 ReactiveForms for 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 uses debounceTime + distinctUntilChanged so you only fire when the user pauses and the value actually changed.

  • Messy event payloads: emitting a single SearchQuery object 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 SearchQuery with 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

Your email address will not be published. Required fields are marked *