Build a Reusable Angular “Searchable Select” Component (Standalone + Reactive Forms + ControlValueAccessor)
Teams end up re-building “select with search” in every project: a dropdown, type-to-filter, keyboard navigation, and a clean way to use it inside Reactive Forms. In this hands-on guide, you’ll build a reusable Angular component that:
- Works with
ReactiveFormsModulelike any native form control - Supports async options (HTTP-loaded lists)
- Filters options as the user types
- Emits a selected value (ID) while displaying a label
- Is a
standalonecomponent (no NgModule required)
You’ll implement ControlValueAccessor, which is the key to making custom form components behave like built-ins.
1) The data model and example usage
We’ll use a simple option model:
export type SelectOption = { value: string; // what the form stores (e.g., userId) label: string; // what the UI shows (e.g., "Ada Lovelace") };
Here’s what using the component will look like in a page:
// users.page.ts import { Component, inject } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { HttpClient } from '@angular/common/http'; import { map, shareReplay } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { SearchableSelectComponent, SelectOption } from './searchable-select.component'; @Component({ standalone: true, selector: 'app-users-page', imports: [ReactiveFormsModule, SearchableSelectComponent], template: ` <form [formGroup]="form" (ngSubmit)="submit()"> <label>Assignee</label> <app-searchable-select formControlName="assigneeId" [options]="userOptions$ | async" placeholder="Pick a user..." emptyText="No matching users"> </app-searchable-select> <p>Selected ID: <code>{{ form.value.assigneeId }}</code></p> <button type="submit" [disabled]="form.invalid">Save</button> </form> ` }) export class UsersPageComponent { private http = inject(HttpClient); form = new FormGroup({ assigneeId: new FormControl('', { nonNullable: true, validators: [Validators.required] }), }); userOptions$: Observable<SelectOption[]> = this.http.get<any[]>('/api/users').pipe( map(users => users.map(u => ({ value: String(u.id), label: u.name }))), shareReplay({ bufferSize: 1, refCount: true }) ); submit() { console.log('Form submit:', this.form.getRawValue()); } }
Notice: the form stores only the selected value (ID), but the dropdown shows label.
2) Create the standalone component skeleton
This component will accept options as an input, plus a placeholder and “empty list” text.
// searchable-select.component.ts import { Component, ElementRef, EventEmitter, HostListener, Input, Output, forwardRef, signal, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormControl } from '@angular/forms'; export type SelectOption = { value: string; label: string }; @Component({ standalone: true, selector: 'app-searchable-select', imports: [CommonModule, ReactiveFormsModule], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SearchableSelectComponent), multi: true } ], template: ` <div class="ss" [class.disabled]="disabled"> <div class="ss-control" (click)="toggle()"> <span class="ss-value">{{ selectedLabel() || placeholder }}</span> <span class="ss-caret">▾</span> </div> <div class="ss-panel" *ngIf="open()"> <input class="ss-search" type="text" [formControl]="search" placeholder="Search..." (keydown)="onKeydown($event)" /> <ul class="ss-list"> <li *ngFor="let opt of filteredOptions(); let i = index" class="ss-item" [class.active]="i === activeIndex()" [class.selected]="opt.value === value()" (click)="select(opt)" > {{ opt.label }} </li> <li *ngIf="filteredOptions().length === 0" class="ss-empty"> {{ emptyText }} </li> </ul> </div> </div> `, styles: [` .ss { position: relative; width: 320px; font-family: system-ui, sans-serif; } .ss.disabled { opacity: 0.6; pointer-events: none; } .ss-control { display:flex; align-items:center; justify-content:space-between; border:1px solid #ccc; padding:10px; border-radius:8px; cursor:pointer; background:#fff; } .ss-panel { position:absolute; top: calc(100% + 6px); left:0; right:0; border:1px solid #ccc; border-radius:8px; background:#fff; z-index:10; overflow:hidden; } .ss-search { width:100%; border:0; border-bottom:1px solid #eee; padding:10px; outline:none; } .ss-list { list-style:none; margin:0; padding:0; max-height:220px; overflow:auto; } .ss-item { padding:10px; cursor:pointer; } .ss-item.active { background:#f3f4f6; } .ss-item.selected { font-weight:600; } .ss-empty { padding:10px; color:#666; } `] }) export class SearchableSelectComponent implements ControlValueAccessor { @Input() options: SelectOption[] = []; @Input() placeholder = 'Select...'; @Input() emptyText = 'No results'; // Optional: notify parent if they want (forms already get updates via CVA) @Output() picked = new EventEmitter<SelectOption>(); // Local UI state search = new FormControl('', { nonNullable: true }); open = signal(false); disabled = false; // Form value (what gets written into your FormControl) value = signal<string>(''); // Keyboard highlight activeIndex = signal(0); filteredOptions = computed(() => { const q = this.search.value.trim().toLowerCase(); if (!q) return this.options; return this.options.filter(o => o.label.toLowerCase().includes(q)); }); selectedLabel = computed(() => { const v = this.value(); return this.options.find(o => o.value === v)?.label ?? ''; }); // ControlValueAccessor hooks private onChange: (v: string) => void = () => {}; private onTouched: () => void = () => {}; writeValue(v: string | null): void { this.value.set(v ?? ''); } registerOnChange(fn: (v: string) => void): void { this.onChange = fn; } registerOnTouched(fn: () => void): void { this.onTouched = fn; } setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } toggle() { if (this.disabled) return; this.open.set(!this.open()); if (this.open()) { this.activeIndex.set(0); this.search.setValue(''); } else { this.onTouched(); } } select(opt: SelectOption) { this.value.set(opt.value); this.onChange(opt.value); this.picked.emit(opt); this.open.set(false); this.onTouched(); } onKeydown(e: KeyboardEvent) { const opts = this.filteredOptions(); if (e.key === 'ArrowDown') { e.preventDefault(); this.activeIndex.set(Math.min(this.activeIndex() + 1, Math.max(opts.length - 1, 0))); } else if (e.key === 'ArrowUp') { e.preventDefault(); this.activeIndex.set(Math.max(this.activeIndex() - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); const opt = opts[this.activeIndex()]; if (opt) this.select(opt); } else if (e.key === 'Escape') { e.preventDefault(); this.open.set(false); this.onTouched(); } } constructor(private host: ElementRef<HTMLElement>) {} // Close dropdown when clicking outside @HostListener('document:mousedown', ['$event']) onDocMouseDown(ev: MouseEvent) { if (!this.open()) return; const target = ev.target as Node | null; if (target && !this.host.nativeElement.contains(target)) { this.open.set(false); this.onTouched(); } } }
At this point, you already have a working component: it filters and selects. But the “real win” is that it integrates with formControlName because we implemented ControlValueAccessor.
3) Why ControlValueAccessor matters (and common mistakes)
When you click an option, two things must happen:
- Update the component UI (
value.set(...)) - Notify Angular forms (
this.onChange(opt.value))
Common mistakes that cause “my custom control doesn’t update the form”:
- Forgetting to provide
NG_VALUE_ACCESSORinproviders - Setting internal state but never calling
onChange - Not calling
onTouchedwhen the control is interacted with (affects touched/untouched validation UX)
4) Make async options safe: handle “value arrives before options”
In real apps, the form value might be set before the options arrive (e.g., editing an entity). Our selectedLabel is computed from options + value, so once options load, the label automatically appears. That’s why deriving label from options is a good pattern.
If you want to guard against invalid values (value not found in options), you can optionally clear it:
// Add inside a method you call when options change, or in ngOnChanges const v = this.value(); if (v && !this.options.some(o => o.value === v)) { this.value.set(''); this.onChange(''); }
5) Add a “trackBy” for large lists (simple performance win)
If options are large or refresh often, reduce DOM churn with trackBy:
// In template (ngFor): *ngFor="let opt of filteredOptions(); let i = index; trackBy: trackByValue" // In class: trackByValue = (_: number, opt: SelectOption) => opt.value;
6) Testing quick sanity (what to verify)
Even without a full test suite, manually verify these cases:
- Form value updates when selecting an item (check
form.value) - Setting the form value programmatically updates the label (edit form scenario)
- Keyboard selection works: Arrow keys + Enter + Escape
- Clicking outside closes the dropdown
- Disabled state prevents interaction (
control.disable())
If you do use Angular’s TestBed, your main assertions are: the CVA calls onChange on selection, and writeValue updates the displayed label once options exist.
7) Next upgrades (if you ship this to production)
aria-*attributes for accessibility (role=listbox, aria-activedescendant)- Virtual scrolling (
cdk-virtual-scroll-viewport) for 10k+ options - Support complex values (store objects) by accepting a
compareWithfunction - Debounced filtering for remote search (call an API as the user types)
You now have a practical, reusable Angular component that behaves like a native form control, supports async data, and is friendly for junior/mid devs to extend. Drop it into your component library and stop re-building “searchable select” in every sprint.
Leave a Reply