Angular course ยท Module 8: Forms

Forms in Angular - The Art of Calligraphy

2 min read ยท
In this lesson2

Forms in Angular are like the art of calligraphy - they require precision, elegance, and offer two main approaches. Every brush stroke (form field) must be accurate!

Two Approaches to Forms

Template-driven Forms

  • Declarative, in the HTML template
  • Simpler, less TypeScript code
  • Good for simple forms
  • Programmatic, in the TypeScript code
  • Full control and testability
  • Better for complex forms

Reactive Forms - Basics

import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-samurai-form',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="samuraiForm" (ngSubmit)="onSubmit()">
      <div class="field">
        <label for="name">Samurai Name</label>
        <input id="name" formControlName="name" placeholder="Musashi">
        @if (samuraiForm.get('name')?.errors?.['required'] &&
             samuraiForm.get('name')?.touched) {
          <span class="error">Name is required</span>
        }
        @if (samuraiForm.get('name')?.errors?.['minlength']) {
          <span class="error">Minimum 2 characters</span>
        }
      </div>

      <div class="field">
        <label for="level">Level</label>
        <input id="level" formControlName="level" type="number">
        @if (samuraiForm.get('level')?.errors?.['min']) {
          <span class="error">Minimum level 1</span>
        }
      </div>

      <div class="field">
        <label for="clan">Clan</label>
        <select id="clan" formControlName="clan">
          <option value="">Select a clan</option>
          <option value="tokugawa">Tokugawa</option>
          <option value="oda">Oda</option>
          <option value="takeda">Takeda</option>
        </select>
      </div>

      <button type="submit" [disabled]="samuraiForm.invalid">
        Save Samurai
      </button>

      <p>Status: {{ samuraiForm.valid ? 'Valid' : 'Invalid' }}</p>
    </form>
  `
})
export class SamuraiFormComponent {
  private fb = inject(FormBuilder);

  samuraiForm = this.fb.group({
    name: ['', [Validators.required, Validators.minLength(2)]],
    level: [1, [Validators.required, Validators.min(1), Validators.max(100)]],
    clan: ['']
  });

  onSubmit(): void {
    if (this.samuraiForm.valid) {
      console.log('Samurai data:', this.samuraiForm.value);
      // { name: 'Musashi', level: 10, clan: 'tokugawa' }
    }
  }
}

Instead of samuraiForm.get('name')?.errors?.['required'] you can ask the control directly with hasError(): the call samuraiForm.get('name')?.hasError('required') returns true when the field has an error with that name. It is usually combined with the touched flag, so the message appears only after the user has left the field:

@if (samuraiForm.get('name')?.hasError('required') && samuraiForm.get('name')?.touched) {
  <span class="error">Name is required</span>
}

Once a name is typed, hasError('required') returns false and the message disappears.

Code for this lesson: src/app/app.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, FormArray, Validators, AbstractControl, ValidationErrors } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  template: `
    <div class="calligraphy-school">
      <h1>Calligraphy School - Angular Forms</h1>

      <form [formGroup]="samuraiForm" (ngSubmit)="onSubmit()">
        <section class="basic-info">
          <h2>Samurai Data</h2>

          <div class="form-group">
            <label for="name">Warrior name *</label>
            <input id="name" formControlName="name" placeholder="Enter name...">
            @if (samuraiForm.get('name')?.touched && samuraiForm.get('name')?.errors) {
              <div class="error">
                @if (samuraiForm.get('name')?.errors?.['required']) {
                  Name is required
                }
                @if (samuraiForm.get('name')?.errors?.['minlength']) {
                  Minimum 3 characters
                }
              </div>
            }
          </div>

          <div class="form-group">
            <label for="clan">Clan</label>
            <select id="clan" formControlName="clan">
              <option value="">Choose a clan...</option>
              <option value="tokugawa">Tokugawa</option>
              <option value="takeda">Takeda</option>
              <option value="oda">Oda</option>
              <option value="shimazu">Shimazu</option>
            </select>
          </div>

          <div class="form-group">
            <label for="age">Age (18-80)</label>
            <input id="age" type="number" formControlName="age">
            @if (samuraiForm.get('age')?.touched && samuraiForm.get('age')?.errors) {
              <div class="error">
                @if (samuraiForm.get('age')?.errors?.['min']) {
                  Minimum 18 years
                }
                @if (samuraiForm.get('age')?.errors?.['max']) {
                  Maximum 80 years
                }
              </div>
            }
          </div>
        </section>

        <section class="skills-section" formGroupName="skills">
          <h2>Skills</h2>

          <div class="skill-sliders">
            <div class="skill">
              <label>Sword: {{ samuraiForm.get('skills.sword')?.value }}</label>
              <input type="range" min="0" max="100" formControlName="sword">
            </div>

            <div class="skill">
              <label>Bow: {{ samuraiForm.get('skills.bow')?.value }}</label>
              <input type="range" min="0" max="100" formControlName="bow">
            </div>

            <div class="skill">
              <label>Strategy: {{ samuraiForm.get('skills.strategy')?.value }}</label>
              <input type="range" min="0" max="100" formControlName="strategy">
            </div>
          </div>

          <div class="total-skills">
            Total points: {{ totalSkillPoints() }} / 200
            @if (samuraiForm.errors?.['skillsLimit']) {
              <span class="error">Limit exceeded!</span>
            }
          </div>
        </section>

        <section class="weapons-section">
          <h2>Armament (FormArray)</h2>

          <div formArrayName="weapons">
            @for (weapon of weapons.controls; track $index; let i = $index) {
              <div class="weapon-row" [formGroupName]="i">
                <input formControlName="name" placeholder="Weapon name">
                <select formControlName="type">
                  <option value="melee">Melee</option>
                  <option value="ranged">Ranged</option>
                </select>
                <input type="number" formControlName="damage" placeholder="Damage">
                <button type="button" class="remove-btn" (click)="removeWeapon(i)">ร—</button>
              </div>
            }
          </div>

          <button type="button" class="add-btn" (click)="addWeapon()">
            + Add weapon
          </button>
        </section>

        <section class="form-actions">
          <div class="status">
            <span [class.valid]="samuraiForm.valid" [class.invalid]="samuraiForm.invalid">
              Form: {{ samuraiForm.valid ? 'Valid' : 'Invalid' }}
            </span>
          </div>

          <button type="submit" [disabled]="samuraiForm.invalid">
            Save Samurai
          </button>

          <button type="button" (click)="resetForm()">
            Clear
          </button>
        </section>
      </form>

      @if (submittedData()) {
        <section class="submitted">
          <h2>Saved data:</h2>
          <pre>{{ submittedData() | json }}</pre>
        </section>
      }
    </div>
  `,
  styles: [`
    .calligraphy-school {
      font-family: 'Segoe UI', sans-serif;
      padding: 20px;
      background: linear-gradient(135deg, #f5f0e8 0%, #e8dcc8 100%);
      min-height: 100vh;
    }

    h1 {
      text-align: center;
      color: #8b4513;
      text-shadow: 1px 1px 2px rgba(0,0,0,0.2);
    }

    h2 {
      color: #8b4513;
      border-bottom: 2px solid #8b4513;
      padding-bottom: 5px;
    }

    form {
      max-width: 600px;
      margin: 0 auto;
    }

    section {
      background: white;
      padding: 20px;
      border-radius: 10px;
      margin: 15px 0;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    }

    .form-group {
      margin-bottom: 15px;
    }

    .form-group label {
      display: block;
      margin-bottom: 5px;
      font-weight: bold;
      color: #5a3d2b;
    }

    .form-group input,
    .form-group select {
      width: 100%;
      padding: 10px;
      border: 2px solid #ddd;
      border-radius: 5px;
      font-size: 16px;
      box-sizing: border-box;
    }

    .form-group input:focus,
    .form-group select:focus {
      border-color: #8b4513;
      outline: none;
    }

    .form-group input.ng-invalid.ng-touched {
      border-color: #dc3545;
    }

    .error {
      color: #dc3545;
      font-size: 0.85em;
      margin-top: 5px;
    }

    .skill-sliders {
      display: flex;
      flex-direction: column;
      gap: 15px;
    }

    .skill {
      display: flex;
      align-items: center;
      gap: 10px;
    }

    .skill label {
      min-width: 120px;
    }

    .skill input[type="range"] {
      flex: 1;
    }

    .total-skills {
      margin-top: 15px;
      padding: 10px;
      background: #f8f4f0;
      border-radius: 5px;
      text-align: center;
    }

    .weapon-row {
      display: flex;
      gap: 10px;
      margin-bottom: 10px;
    }

    .weapon-row input,
    .weapon-row select {
      padding: 8px;
      border: 1px solid #ddd;
      border-radius: 4px;
    }

    .weapon-row input[type="text"] {
      flex: 2;
    }

    .weapon-row input[type="number"] {
      width: 80px;
    }

    .remove-btn {
      background: #dc3545;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      padding: 8px 12px;
    }

    .add-btn {
      background: #28a745;
      color: white;
      border: none;
      padding: 10px 20px;
      border-radius: 5px;
      cursor: pointer;
      margin-top: 10px;
    }

    .form-actions {
      display: flex;
      gap: 15px;
      align-items: center;
      flex-wrap: wrap;
    }

    .status {
      flex: 1;
    }

    .status .valid {
      color: #28a745;
    }

    .status .invalid {
      color: #dc3545;
    }

    .form-actions button[type="submit"] {
      background: #8b4513;
      color: white;
      border: none;
      padding: 12px 24px;
      border-radius: 5px;
      cursor: pointer;
      font-size: 16px;
    }

    .form-actions button[type="submit"]:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }

    .form-actions button[type="button"] {
      background: #6c757d;
      color: white;
      border: none;
      padding: 12px 24px;
      border-radius: 5px;
      cursor: pointer;
    }

    .submitted {
      background: #d4edda;
      border: 1px solid #c3e6cb;
    }

    .submitted pre {
      background: white;
      padding: 15px;
      border-radius: 5px;
      overflow: auto;
    }
  `]
})
export class AppComponent {
  samuraiForm: FormGroup;
  submittedData = signal<any>(null);

  constructor(private fb: FormBuilder) {
    this.samuraiForm = this.fb.group({
      name: ['', [Validators.required, Validators.minLength(3)]],
      clan: [''],
      age: [25, [Validators.min(18), Validators.max(80)]],
      skills: this.fb.group({
        sword: [50],
        bow: [30],
        strategy: [40]
      }, { validators: this.skillsLimitValidator }),
      weapons: this.fb.array([])
    });

    // Add initial weapon
    this.addWeapon();
  }

  get weapons(): FormArray {
    return this.samuraiForm.get('weapons') as FormArray;
  }

  totalSkillPoints(): number {
    const skills = this.samuraiForm.get('skills')?.value;
    return (skills?.sword || 0) + (skills?.bow || 0) + (skills?.strategy || 0);
  }

  skillsLimitValidator(control: AbstractControl): ValidationErrors | null {
    const skills = control.value;
    const total = (skills?.sword || 0) + (skills?.bow || 0) + (skills?.strategy || 0);
    return total > 200 ? { skillsLimit: true } : null;
  }

  addWeapon(): void {
    const weaponGroup = this.fb.group({
      name: ['', Validators.required],
      type: ['melee'],
      damage: [10, [Validators.min(1), Validators.max(100)]]
    });
    this.weapons.push(weaponGroup);
  }

  removeWeapon(index: number): void {
    this.weapons.removeAt(index);
  }

  onSubmit(): void {
    if (this.samuraiForm.valid) {
      this.submittedData.set(this.samuraiForm.value);
    }
  }

  resetForm(): void {
    this.samuraiForm.reset({
      name: '',
      clan: '',
      age: 25,
      skills: { sword: 50, bow: 30, strategy: 40 }
    });
    this.weapons.clear();
    this.addWeapon();
    this.submittedData.set(null);
  }
}

Docs entries used in this lesson

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does FormControl represent in Reactive Forms?

  2. 2. Which approach to forms in Angular is recommended for complex forms?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Code editor

    Complete the code that creates a FormGroup for samurai registration.

  • Horizontal ordering

    Arrange the tokens of the ReactiveFormsModule import statement (one line, left to right).

  • Horizontal ordering

    Arrange the tokens that bind an input field to a form control (one line).

  • Click in order

    Build the expression that reads the 'required' error of the name field: form.get('name')?.errors?.['required']. Click the elements in order.

Useful articles