Angular Forms: Select Dropdowns, Checkboxes, Radio Buttons, Typed Forms and FormRecord

Angular Tutorial: Beginner to Advanced · 8. Forms and validation · 6 min read · Updated

Text inputs are the easy part. Dropdowns, radio buttons and checkbox lists each have small tricks. This lesson also shows how typed forms protect you from mistakes, and how FormRecord handles groups of controls with keys you do not know in advance.

What you will learn

  • select, radio and checkbox fields in a reactive form
  • Binding objects to a select with compareWith
  • Typed forms and FormRecord

A form with every field type

// src/app/forms3/order-form.ts
import { JsonPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

interface Country {
  code: string;
  name: string;
}

@Component({
  selector: 'app-order-form',
  imports: [ReactiveFormsModule, JsonPipe],
  template: `
    <form [formGroup]="form" (ngSubmit)="submit()">
      <!-- 1. Select with simple string values -->
      <p>
        <label for="size">Size</label><br />
        <select id="size" formControlName="size">
          <option value="" disabled>Choose a size</option>
          @for (s of sizes; track s) {
            <option [value]="s">{{ s }}</option>
          }
        </select>
      </p>

      <!-- 2. Select bound to whole objects -->
      <p>
        <label for="country">Country</label><br />
        <select id="country" formControlName="country" [compareWith]="sameCountry">
          @for (c of countries; track c.code) {
            <option [ngValue]="c">{{ c.name }}</option>
          }
        </select>
      </p>

      <!-- 3. Radio buttons share one control -->
      <fieldset>
        <legend>Delivery</legend>
        @for (d of deliveries; track d) {
          <label><input type="radio" formControlName="delivery" [value]="d" /> {{ d }}</label>
        }
      </fieldset>

      <!-- 4. A single checkbox: a boolean -->
      <p><label><input type="checkbox" formControlName="gift" /> This is a gift</label></p>

      <!-- 5. Several checkboxes: one boolean per option, in a group -->
      <fieldset formGroupName="extras">
        <legend>Extras</legend>
        @for (e of extraNames; track e) {
          <label><input type="checkbox" [formControlName]="e" /> {{ e }}</label>
        }
      </fieldset>

      <!-- 6. Multiple select: an array of values -->
      <p>
        <label for="colors">Colours</label><br />
        <select id="colors" multiple formControlName="colors">
          @for (c of colors; track c) {
            <option [value]="c">{{ c }}</option>
          }
        </select>
      </p>

      <button type="submit" [disabled]="form.invalid">Order</button>
    </form>
    <pre>{{ form.getRawValue() | json }}</pre>
  `,
})
export class OrderForm {
  private readonly fb = inject(FormBuilder).nonNullable;

  protected readonly sizes = ['S', 'M', 'L'];
  protected readonly deliveries = ['Standard', 'Express'];
  protected readonly colors = ['Red', 'Green', 'Blue'];
  protected readonly extraNames = ['Gift wrap', 'Insurance', 'Priority support'];
  protected readonly countries: Country[] = [
    { code: 'IN', name: 'India' },
    { code: 'DE', name: 'Germany' },
    { code: 'US', name: 'United States' },
  ];

  protected readonly form = this.fb.group({
    size: ['', Validators.required],
    country: [this.countries[0]],
    delivery: ['Standard'],
    gift: [false],
    extras: this.fb.group(Object.fromEntries(this.extraNames.map((n) => [n, false]))),
    colors: [[] as string[]],
  });

  // Two Country objects are "the same" when their codes match, even if they are different objects.
  protected sameCountry(a: Country | null, b: Country | null): boolean {
    return a?.code === b?.code;
  }

  protected submit(): void {
    const chosen = Object.entries(this.form.controls.extras.getRawValue())
      .filter(([, on]) => on)
      .map(([name]) => name);
    console.log('Order', this.form.getRawValue(), 'extras:', chosen);
  }
}

Points to remember:

  • `[value]` on an <option> or radio button sets a string value. `[ngValue]` can set any value, such as an object.
  • When the control holds an object, Angular needs to know when two objects are "the same" (for example, after loading data from a server, you get a new object with the same code). [compareWith] provides that test. Without it, the select may show nothing as selected.
  • A radio group is one control. Give every radio the same formControlName.
  • One checkbox is a boolean. A list of checkboxes is easiest as a group of booleans (one control per option), then you filter the true ones.
  • <select multiple> yields an array of selected values.
  • Add a disabled, empty first option as a placeholder.

Typed forms

Since Angular 14, forms are typed. The compiler knows each control's type, so mistakes show up while you type:

// src/app/forms3/typed-demo.ts
import { FormControl, FormGroup, Validators } from '@angular/forms';

// Types are inferred:
const age = new FormControl(18, { nonNullable: true }); // FormControl<number>
const nick = new FormControl('x'); // FormControl<string | null> (reset() gives null)

// You can write the type out too:
interface LoginForm {
  email: FormControl<string>;
  remember: FormControl<boolean>;
}

export const login = new FormGroup<LoginForm>({
  email: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
  remember: new FormControl(false, { nonNullable: true }),
});

// login.controls.email.value is a string (not any)
// login.controls.emial  -> compile error: no such control
// login.value is Partial<{ email: string; remember: boolean }> because disabled controls are omitted
// login.getRawValue() is { email: string; remember: boolean }

Benefits: autocomplete, errors for wrong names and wrong types, and safe refactoring. Old code used UntypedFormControl and friends, which Angular's migration added automatically when typed forms arrived. Replace them step by step. Use nonNullable everywhere you can, because null values are the usual source of type headaches.

FormRecord: controls with dynamic keys

A FormGroup has known keys. A `FormRecord` has keys that are not known at compile time but all controls have the same type, such as "one checkbox per tag that comes from the server":

// src/app/forms3/tag-picker.ts
import { JsonPipe } from '@angular/common';
import { Component } from '@angular/core';
import { FormControl, FormRecord, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-tag-picker',
  imports: [ReactiveFormsModule, JsonPipe],
  template: `
    <button type="button" (click)="addTag('news')">Add "news"</button>
    <button type="button" (click)="addTag('sport')">Add "sport"</button>
    <button type="button" (click)="removeTag('news')">Remove "news"</button>

    <div [formGroup]="tags">
      @for (name of names(); track name) {
        <label><input type="checkbox" [formControlName]="name" /> {{ name }}</label>
      }
    </div>
    <p>Selected: {{ selected().join(', ') || 'none' }}</p>
    <pre>{{ tags.getRawValue() | json }}</pre>
  `,
})
export class TagPicker {
  protected readonly tags = new FormRecord<FormControl<boolean>>({});

  protected names(): string[] {
    return Object.keys(this.tags.controls);
  }

  protected selected(): string[] {
    return Object.entries(this.tags.getRawValue())
      .filter(([, on]) => on)
      .map(([name]) => name);
  }

  protected addTag(name: string): void {
    if (!this.tags.contains(name)) {
      this.tags.addControl(name, new FormControl(false, { nonNullable: true }));
    }
  }

  protected removeTag(name: string): void {
    this.tags.removeControl(name);
  }
}

FormRecord<FormControl<boolean>> says: every control is a boolean. You can add and remove keys at run time with addControl and removeControl, and getRawValue() returns Record<string, boolean>.

Use FormGroup for fixed fields (like email, password), FormArray for ordered lists of rows, and FormRecord for named items whose names come from data.

// src/app/app.ts
import { Component } from '@angular/core';
import { OrderForm } from './forms3/order-form';
import { TagPicker } from './forms3/tag-picker';

@Component({
  imports: [OrderForm, TagPicker],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <h1>Field types</h1>
  <app-order-form />
  <app-tag-picker />
</main>

Tests

// src/app/forms3/tag-picker.spec.ts
import { FormControl, FormRecord } from '@angular/forms';

describe('FormRecord', () => {
  it('adds and removes controls by name', () => {
    const tags = new FormRecord<FormControl<boolean>>({});
    tags.addControl('news', new FormControl(true, { nonNullable: true }));
    tags.addControl('sport', new FormControl(false, { nonNullable: true }));
    expect(tags.getRawValue()).toEqual({ news: true, sport: false });

    tags.removeControl('news');
    expect(Object.keys(tags.controls)).toEqual(['sport']);
  });

  it('tracks validity across all controls', () => {
    const tags = new FormRecord<FormControl<boolean>>({});
    tags.addControl('a', new FormControl(false, { nonNullable: true }));
    expect(tags.valid).toBe(true);
    tags.disable();
    expect(tags.disabled).toBe(true);
  });
});

Try it yourself

  1. Make the extras section required to pick at least one option (a custom validator on the group).
  2. Preload the form with patchValue from an object whose country comes from a server, and confirm compareWith selects it.
  3. Try to write login.controls.emial and read the compiler message.

Quick check

  1. When do you use [ngValue] instead of [value]?
  2. What is compareWith for?
  3. When would you pick FormRecord over FormGroup?

Next, learn every kind of validation in advanced validation.

Frequently asked questions

Why does my select show a blank option after loading data?

The loaded value does not match any option. For strings, check exact spelling and case. For objects, add compareWith.

Can I use typed forms with FormBuilder?

Yes. fb.group infers types too. Use fb.nonNullable to avoid null in the types.