User Input intermediate

Debounced Search Input with Signals

Implement efficient search with automatic debouncing using signals and computed values.

Alex Muturi
June 12, 2025
18 min read
#signals #debounce #search #forms #async #performance

Debounced Search Input with Signals

Debouncing search inputs is a common requirement to prevent excessive API calls while the user is typing. This recipe shows how to implement this pattern using Angular Signals with proper loading states and error handling.

The Problem

Without debouncing:

  • Every keystroke triggers an API call
  • Server gets overwhelmed with requests
  • User sees flickering results
  • Wasted bandwidth and resources

With proper debouncing:

  • Wait for user to finish typing
  • Single API call after pause
  • Smooth user experience
  • Efficient resource usage

Complete Solution

import { Component, signal, computed, effect, ChangeDetectionStrategy } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { catchError, debounceTime, distinctUntilChanged, switchMap, of } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';

interface SearchResult {
  id: number;
  title: string;
  description: string;
  url: string;
}

interface SearchState {
  results: SearchResult[];
  loading: boolean;
  error: string | null;
}

@Component({
  selector: 'app-debounced-search',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule],
  template: `
    <div class="search-container">
      <div class="search-box">
        <input
          type="text"
          [value]="searchQuery()"
          (input)="onSearchInput($event)"
          placeholder="Search for articles..."
          class="search-input"
          [class.loading]="isLoading()"
        />
        
        @if (isLoading()) {
          <div class="spinner"></div>
        }
        
        @if (searchQuery() && !isLoading()) {
          <button 
            class="clear-btn"
            (click)="clearSearch()"
            aria-label="Clear search"
          >
            ×
          </button>
        }
      </div>

      <div class="search-info">
        @if (debouncedQuery() && debouncedQuery() !== searchQuery()) {
          <span class="debounce-indicator">
            Waiting for you to finish typing...
          </span>
        }
        
        @if (searchState().results.length > 0) {
          <span class="results-count">
            Found {{ searchState().results.length }} results
          </span>
        }
      </div>

      @if (searchState().error) {
        <div class="error-message">
          <span>⚠️ {{ searchState().error }}</span>
          <button (click)="retrySearch()">Retry</button>
        </div>
      }

      @if (searchState().loading) {
        <div class="loading-skeleton">
          @for (i of [1,2,3]; track i) {
            <div class="skeleton-item"></div>
          }
        </div>
      } @else if (searchState().results.length > 0) {
        <div class="results-list">
          @for (result of searchState().results; track result.id) {
            <article class="result-item">
              <h3>{{ result.title }}</h3>
              <p>{{ result.description }}</p>
              <a [href]="result.url" target="_blank">Read more →</a>
            </article>
          }
        </div>
      } @else if (debouncedQuery() && !searchState().loading) {
        <div class="no-results">
          <p>No results found for "{{ debouncedQuery() }}"</p>
          <p class="hint">Try different keywords or check your spelling</p>
        </div>
      } @else if (!searchQuery()) {
        <div class="empty-state">
          <svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor">
            <circle cx="11" cy="11" r="8"></circle>
            <path d="m21 21-4.35-4.35"></path>
          </svg>
          <p>Start typing to search</p>
        </div>
      }
    </div>
  `,
  styles: [`
    .search-container {
      max-width: 600px;
      margin: 0 auto;
      padding: 20px;
    }

    .search-box {
      position: relative;
      display: flex;
      align-items: center;
    }

    .search-input {
      width: 100%;
      padding: 12px 40px 12px 16px;
      font-size: 16px;
      border: 2px solid #e0e0e0;
      border-radius: 8px;
      transition: all 0.2s;
    }

    .search-input:focus {
      outline: none;
      border-color: #2196F3;
      box-shadow: 0 0 0 3px rgba(33, 150, 243, 0.1);
    }

    .search-input.loading {
      padding-right: 48px;
    }

    .spinner {
      position: absolute;
      right: 12px;
      width: 20px;
      height: 20px;
      border: 2px solid #f3f3f3;
      border-top: 2px solid #2196F3;
      border-radius: 50%;
      animation: spin 0.8s linear infinite;
    }

    @keyframes spin {
      0% { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }

    .clear-btn {
      position: absolute;
      right: 12px;
      width: 24px;
      height: 24px;
      border: none;
      background: #ccc;
      color: white;
      border-radius: 50%;
      cursor: pointer;
      font-size: 18px;
      line-height: 1;
      padding: 0;
      display: flex;
      align-items: center;
      justify-content: center;
    }

    .clear-btn:hover {
      background: #999;
    }

    .search-info {
      margin-top: 8px;
      min-height: 20px;
      font-size: 14px;
      color: #666;
    }

    .debounce-indicator {
      color: #FF9800;
    }

    .results-count {
      color: #4CAF50;
    }

    .error-message {
      margin-top: 16px;
      padding: 12px;
      background: #ffebee;
      border-left: 4px solid #f44336;
      border-radius: 4px;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }

    .error-message button {
      padding: 6px 12px;
      background: #f44336;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }

    .loading-skeleton {
      margin-top: 16px;
    }

    .skeleton-item {
      height: 100px;
      background: linear-gradient(
        90deg,
        #f0f0f0 25%,
        #e0e0e0 50%,
        #f0f0f0 75%
      );
      background-size: 200% 100%;
      animation: loading 1.5s infinite;
      border-radius: 8px;
      margin-bottom: 12px;
    }

    @keyframes loading {
      0% { background-position: 200% 0; }
      100% { background-position: -200% 0; }
    }

    .results-list {
      margin-top: 16px;
    }

    .result-item {
      padding: 16px;
      background: white;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      margin-bottom: 12px;
      transition: all 0.2s;
    }

    .result-item:hover {
      box-shadow: 0 4px 12px rgba(0,0,0,0.1);
      transform: translateY(-2px);
    }

    .result-item h3 {
      margin: 0 0 8px 0;
      color: #2196F3;
    }

    .result-item p {
      margin: 0 0 12px 0;
      color: #666;
      line-height: 1.5;
    }

    .result-item a {
      color: #2196F3;
      text-decoration: none;
      font-weight: 500;
    }

    .result-item a:hover {
      text-decoration: underline;
    }

    .no-results, .empty-state {
      margin-top: 40px;
      text-align: center;
      color: #999;
    }

    .empty-state svg {
      margin-bottom: 16px;
      color: #ccc;
    }

    .no-results p:first-child {
      font-size: 18px;
      color: #666;
      margin-bottom: 8px;
    }

    .no-results .hint {
      font-size: 14px;
    }
  `]
})
export class DebouncedSearchComponent {
  private http = inject(HttpClient);

  // User's input (updates immediately)
  searchQuery = signal('');

  // Debounced query (updates after delay)
  debouncedQuery = signal('');

  // Search results state
  searchState = signal<SearchState>({
    results: [],
    loading: false,
    error: null
  });

  // Computed loading state for UI
  isLoading = computed(() => this.searchState().loading);

  // Debounce timer
  private debounceTimer: any = null;
  private readonly DEBOUNCE_MS = 500;

  constructor() {
    // Effect to perform search when debounced query changes
    effect(() => {
      const query = this.debouncedQuery();
      
      if (!query) {
        this.searchState.set({
          results: [],
          loading: false,
          error: null
        });
        return;
      }

      this.performSearch(query);
    });
  }

  onSearchInput(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.searchQuery.set(value);

    // Clear existing timer
    if (this.debounceTimer) {
      clearTimeout(this.debounceTimer);
    }

    // Set new timer
    this.debounceTimer = setTimeout(() => {
      this.debouncedQuery.set(value);
    }, this.DEBOUNCE_MS);
  }

  clearSearch() {
    this.searchQuery.set('');
    this.debouncedQuery.set('');
    if (this.debounceTimer) {
      clearTimeout(this.debounceTimer);
    }
  }

  retrySearch() {
    const query = this.debouncedQuery();
    if (query) {
      this.performSearch(query);
    }
  }

  private async performSearch(query: string) {
    // Set loading state
    this.searchState.update(state => ({
      ...state,
      loading: true,
      error: null
    }));

    try {
      // Simulate API call (replace with real API)
      const results = await this.mockApiSearch(query);
      
      this.searchState.set({
        results,
        loading: false,
        error: null
      });
    } catch (error) {
      this.searchState.set({
        results: [],
        loading: false,
        error: 'Failed to fetch results. Please try again.'
      });
    }
  }

  private async mockApiSearch(query: string): Promise<SearchResult[]> {
    // Simulate network delay
    await new Promise(resolve => setTimeout(resolve, 800));

    // Mock data
    const allResults: SearchResult[] = [
      {
        id: 1,
        title: 'Introduction to Angular Signals',
        description: 'Learn the basics of Angular Signals and reactive programming.',
        url: '/tutorials/angular-signals-basics'
      },
      {
        id: 2,
        title: 'Computed Signals Explained',
        description: 'Deep dive into computed signals and derived state.',
        url: '/tutorials/computed-signals'
      },
      {
        id: 3,
        title: 'Effect Management in Angular',
        description: 'Best practices for using effects with signals.',
        url: '/tutorials/understanding-effects'
      },
      {
        id: 4,
        title: 'Signal-Based Form Validation',
        description: 'Build reactive forms with signal-based validation.',
        url: '/recipes/signal-form-validation'
      },
      {
        id: 5,
        title: 'Performance Optimization with Signals',
        description: 'Optimize your Angular app using signals effectively.',
        url: '/best-practices/performance-optimization'
      }
    ];

    // Filter based on query
    const lowercaseQuery = query.toLowerCase();
    return allResults.filter(result =>
      result.title.toLowerCase().includes(lowercaseQuery) ||
      result.description.toLowerCase().includes(lowercaseQuery)
    );
  }
}

Alternative: Using RxJS Interop

For scenarios where you want to leverage RxJS operators:

import { Component, signal, computed } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap, catchError, of, map } from 'rxjs';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-rxjs-debounced-search',
  standalone: true,
  template: `
    <div class="search-container">
      <input
        type="text"
        [value]="searchQuery()"
        (input)="onSearchInput($event)"
        placeholder="Search..."
      />

      @if (isLoading()) {
        <div class="loading">Searching...</div>
      }

      @if (results()) {
        <div class="results">
          @for (result of results(); track result.id) {
            <div class="result-item">
              <h3>{{ result.title }}</h3>
              <p>{{ result.description }}</p>
            </div>
          }
        </div>
      }
    </div>
  `
})
export class RxJSDebouncedSearchComponent {
  private http = inject(HttpClient);

  searchQuery = signal('');

  // Convert signal to observable for RxJS operators
  private searchQuery$ = toObservable(this.searchQuery);

  // Use RxJS operators for debouncing and API calls
  private searchResults$ = this.searchQuery$.pipe(
    debounceTime(500),
    distinctUntilChanged(),
    switchMap(query => {
      if (!query) {
        return of([]);
      }
      return this.http.get<SearchResult[]>(`/api/search?q=${query}`).pipe(
        catchError(() => of([]))
      );
    })
  );

  // Convert back to signal
  results = toSignal(this.searchResults$, { initialValue: [] });

  isLoading = computed(() => {
    // Simple loading detection
    return this.searchQuery().length > 0 && this.results().length === 0;
  });

  onSearchInput(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.searchQuery.set(value);
  }
}

Advanced: Custom Debounce Utility

Create a reusable debounce utility for signals:

import { signal, computed, effect, Signal } from '@angular/core';

export function createDebouncedSignal<T>(
  sourceSignal: Signal<T>,
  delayMs: number
): Signal<T> {
  const debouncedValue = signal(sourceSignal());
  let timeoutId: any = null;

  effect(() => {
    const currentValue = sourceSignal();
    
    if (timeoutId) {
      clearTimeout(timeoutId);
    }

    timeoutId = setTimeout(() => {
      debouncedValue.set(currentValue);
    }, delayMs);
  });

  return debouncedValue.asReadonly();
}

// Usage
@Component({
  selector: 'app-search',
  standalone: true,
  template: `
    <input [value]="searchQuery()" (input)="updateQuery($event)" />
    <p>Immediate: {{ searchQuery() }}</p>
    <p>Debounced: {{ debouncedQuery() }}</p>
  `
})
export class SearchComponent {
  searchQuery = signal('');
  debouncedQuery = createDebouncedSignal(this.searchQuery, 500);

  constructor() {
    // Effect runs only after debounce delay
    effect(() => {
      const query = this.debouncedQuery();
      if (query) {
        console.log('Searching for:', query);
        // Perform search
      }
    });
  }

  updateQuery(event: Event) {
    this.searchQuery.set((event.target as HTMLInputElement).value);
  }
}

With TypeAhead Suggestions

Enhanced version with type-ahead suggestions:

import { Component, signal, computed, effect, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Suggestion {
  id: string;
  text: string;
  category?: string;
}

@Component({
  selector: 'app-typeahead-search',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [FormsModule],
  template: `
    <div class="typeahead-container">
      <div class="search-box">
        <input
          type="text"
          [value]="searchQuery()"
          (input)="onInput($event)"
          (focus)="showSuggestions.set(true)"
          (blur)="onBlur()"
          (keydown)="onKeyDown($event)"
          placeholder="Search..."
          class="search-input"
        />
      </div>

      @if (showSuggestions() && suggestions().length > 0) {
        <ul class="suggestions-list">
          @for (suggestion of suggestions(); track suggestion.id; let i = $index) {
            <li
              class="suggestion-item"
              [class.highlighted]="highlightedIndex() === i"
              (mousedown)="selectSuggestion(suggestion)"
              (mouseenter)="highlightedIndex.set(i)"
            >
              <span class="suggestion-text">{{ suggestion.text }}</span>
              @if (suggestion.category) {
                <span class="suggestion-category">{{ suggestion.category }}</span>
              }
            </li>
          }
        </ul>
      }

      @if (selectedSuggestion()) {
        <div class="selected">
          Selected: {{ selectedSuggestion()!.text }}
        </div>
      }
    </div>
  `,
  styles: [`
    .typeahead-container {
      position: relative;
      max-width: 400px;
    }

    .search-input {
      width: 100%;
      padding: 12px 16px;
      font-size: 16px;
      border: 2px solid #e0e0e0;
      border-radius: 8px;
    }

    .suggestions-list {
      position: absolute;
      top: 100%;
      left: 0;
      right: 0;
      margin: 4px 0;
      padding: 0;
      list-style: none;
      background: white;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      box-shadow: 0 4px 12px rgba(0,0,0,0.1);
      max-height: 300px;
      overflow-y: auto;
      z-index: 1000;
    }

    .suggestion-item {
      padding: 12px 16px;
      cursor: pointer;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }

    .suggestion-item:hover,
    .suggestion-item.highlighted {
      background: #f5f5f5;
    }

    .suggestion-text {
      flex: 1;
    }

    .suggestion-category {
      font-size: 12px;
      color: #999;
      background: #f0f0f0;
      padding: 2px 8px;
      border-radius: 12px;
    }

    .selected {
      margin-top: 12px;
      padding: 12px;
      background: #e3f2fd;
      border-radius: 4px;
      color: #1976d2;
    }
  `]
})
export class TypeaheadSearchComponent {
  searchQuery = signal('');
  debouncedQuery = signal('');
  showSuggestions = signal(false);
  highlightedIndex = signal(-1);
  selectedSuggestion = signal<Suggestion | null>(null);

  private debounceTimer: any = null;

  // Mock suggestions based on query
  suggestions = computed(() => {
    const query = this.debouncedQuery().toLowerCase();
    if (!query) return [];

    const allSuggestions: Suggestion[] = [
      { id: '1', text: 'Angular Signals', category: 'Tutorial' },
      { id: '2', text: 'Computed Signals', category: 'Tutorial' },
      { id: '3', text: 'Effect Management', category: 'Tutorial' },
      { id: '4', text: 'Form Validation', category: 'Recipe' },
      { id: '5', text: 'Debounced Search', category: 'Recipe' },
      { id: '6', text: 'Custom Equality', category: 'Pattern' },
      { id: '7', text: 'Performance Tips', category: 'Best Practice' },
    ];

    return allSuggestions.filter(s =>
      s.text.toLowerCase().includes(query)
    );
  });

  constructor() {
    // Reset highlighted index when suggestions change
    effect(() => {
      this.suggestions();
      this.highlightedIndex.set(-1);
    });
  }

  onInput(event: Event) {
    const value = (event.target as HTMLInputElement).value;
    this.searchQuery.set(value);
    this.showSuggestions.set(true);

    if (this.debounceTimer) {
      clearTimeout(this.debounceTimer);
    }

    this.debounceTimer = setTimeout(() => {
      this.debouncedQuery.set(value);
    }, 300);
  }

  onKeyDown(event: KeyboardEvent) {
    const suggestionsCount = this.suggestions().length;
    if (suggestionsCount === 0) return;

    switch (event.key) {
      case 'ArrowDown':
        event.preventDefault();
        this.highlightedIndex.update(i => 
          i < suggestionsCount - 1 ? i + 1 : 0
        );
        break;

      case 'ArrowUp':
        event.preventDefault();
        this.highlightedIndex.update(i => 
          i > 0 ? i - 1 : suggestionsCount - 1
        );
        break;

      case 'Enter':
        event.preventDefault();
        const index = this.highlightedIndex();
        if (index >= 0 && index < suggestionsCount) {
          this.selectSuggestion(this.suggestions()[index]);
        }
        break;

      case 'Escape':
        this.showSuggestions.set(false);
        break;
    }
  }

  selectSuggestion(suggestion: Suggestion) {
    this.searchQuery.set(suggestion.text);
    this.selectedSuggestion.set(suggestion);
    this.showSuggestions.set(false);
  }

  onBlur() {
    // Delay to allow click events to fire
    setTimeout(() => {
      this.showSuggestions.set(false);
    }, 200);
  }
}

Testing

import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing';
import { DebouncedSearchComponent } from './debounced-search.component';

describe('DebouncedSearchComponent', () => {
  let component: DebouncedSearchComponent;
  let fixture: ComponentFixture<DebouncedSearchComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [DebouncedSearchComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(DebouncedSearchComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should debounce search input', fakeAsync(() => {
    const input = fixture.nativeElement.querySelector('.search-input');

    // Type quickly
    input.value = 'a';
    input.dispatchEvent(new Event('input'));
    expect(component.debouncedQuery()).toBe('');

    input.value = 'an';
    input.dispatchEvent(new Event('input'));
    expect(component.debouncedQuery()).toBe('');

    input.value = 'ang';
    input.dispatchEvent(new Event('input'));
    expect(component.debouncedQuery()).toBe('');

    // Wait for debounce
    tick(500);
    expect(component.debouncedQuery()).toBe('ang');
  }));

  it('should clear search', () => {
    component.searchQuery.set('test');
    component.debouncedQuery.set('test');

    component.clearSearch();

    expect(component.searchQuery()).toBe('');
    expect(component.debouncedQuery()).toBe('');
  });

  it('should show loading state', fakeAsync(() => {
    component.searchQuery.set('angular');
    tick(500);
    
    expect(component.isLoading()).toBe(true);
    
    tick(800); // Wait for mock API
    expect(component.isLoading()).toBe(false);
  }));
});

Key Takeaways

  1. Debouncing prevents excessive API calls while maintaining responsive UI
  2. Signals make state management simple with clear reactive dependencies
  3. Loading states improve UX by providing feedback during searches
  4. Error handling ensures robustness with retry capabilities
  5. Type-ahead enhances usability with keyboard navigation support

Next Steps

References

Share this article