Skip to content

MarcelloDiSimone/reactive-forms-stackblitz

Repository files navigation

Angular Reactive Forms — Patterns & Recipes

Table of Contents


Form Architecture

The form is defined in a single parent component using NonNullableFormBuilder and strongly typed with a CombinedForm interface. Each section is delegated to a child component via formGroupName / formArrayName.

// main.models.ts
export type CombinedForm = {
  user: FormGroup<UserForm>;
  profile: FormGroup<ProfileForm>;
  address: FormGroup<AddressForm>;
  keywords: FormGroup<KeywordsForm>;
  links: FormArray<FormGroup<LinkForm>>;
  language: FormArray<FormControl<string>>;
};
<!-- form.html -->
<form [formGroup]="form" (submit)="submit()">
  <app-user formGroupName="user"></app-user>
  <app-profile formGroupName="profile"></app-profile>
  <app-address formGroupName="address"></app-address>
  <app-links formGroupName="links"></app-links>
  <app-keywords formGroupName="keywords"></app-keywords>
  <app-languages formArrayName="language"></app-languages>
</form>

Child components never wrap their template in a <form> tag — they use <div [formGroup]="form"> instead, avoiding invalid nested <form> elements.


Child Component Forms with ControlContainer

Child components receive their slice of the parent form via ControlContainer injection. No @Input() needed.

// user.ts
@Component({
  selector: 'app-user',
  templateUrl: './user.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserComponent implements OnInit {
  private readonly controlContainer = inject(ControlContainer);
  form!: FormGroup<UserForm>;

  ngOnInit(): void {
    this.form = this.controlContainer.control as FormGroup;
  }
}

The parent binds the sub-group with formGroupName="user", and ControlContainer resolves to the corresponding FormGroup<UserForm>.


Extending the Parent Form from a Child Component

When a child component needs to dynamically create controls that the parent doesn't know about upfront, it can extend the parent-provided FormGroup in ngOnInit.

// keywords.ts — adds a FormControl<boolean> per keyword into the parent's empty FormGroup
export class KeywordsComponent implements OnInit {
  private readonly controlContainer = inject(ControlContainer);
  form!: FormGroup<KeywordsForm>;

  keywords = KEYWORDS;

  ngOnInit(): void {
    this.form = this.controlContainer.control as FormGroup;
    this.keywords.map((keyword) => {
      this.form.addControl(
        keyword.value,
        new FormControl(false, { nonNullable: true }),
      );
    });
  }
}

The parent defines just an empty group — the child populates it:

// form.ts (parent)
form = this.fb.group<CombinedForm>({
  // ...
  keywords: this.fb.group({}), // child fills this in ngOnInit
});

For FormArray-based children like LanguagesComponent, the same pattern applies but with push/remove semantics:

// languages.ts — manages a FormArray<FormControl<string>> via checkbox events
export class LanguagesComponent {
  private readonly controlContainer = inject(ControlContainer);
  private readonly fb = inject(FormBuilder);
  form!: FormArray;

  ngOnInit(): void {
    this.form = this.controlContainer.control as FormArray;
  }

  onChange(event: Event) {
    const checkbox = event.target as HTMLInputElement;
    if (checkbox.checked) {
      this.form.push(this.fb.control(checkbox.value));
    } else {
      const index = this.form.controls.findIndex(
        (c) => c.value === checkbox.value,
      );
      if (index !== -1) this.form.removeAt(index);
    }
  }
}
<!-- languages.html — no formGroup binding, operates on the FormArray directly -->
@for (lang of languages; track lang.value; let i = $index) {
  <input
    type="checkbox"
    id="language-{{ i }}"
    [value]="lang.value"
    (change)="onChange($event)"
  />
  <label for="language-{{ i }}">{{ lang.label }}</label>
}

Dynamic FormControls with FormArray

LinksComponent demonstrates adding and removing typed FormGroups within a FormArray.

// links.ts
export class Links {
  private readonly controlContainer = inject(ControlContainer);
  private readonly fb = inject(NonNullableFormBuilder);

  form!: FormArray<FormGroup<LinkForm>>;

  ngOnInit(): void {
    this.form = this.controlContainer.control as FormArray;
  }

  addLink(): void {
    this.form.push(
      this.fb.group<LinkForm>({
        url: this.fb.control('', {
          validators: [Validators.required, urlValidator],
        }),
        title: this.fb.control('', {
          validators: [Validators.required],
        }),
      }),
    );
  }

  removeLink(index: number): void {
    this.form.removeAt(index);
  }
}
<!-- links.html -->
@for (link of form.controls; track link; let i = $index) {
  <div [formGroup]="link">
    <input formControlName="url" />
    <input formControlName="title" />
    <button type="button" (click)="removeLink(i)">Remove</button>
  </div>
}
<button type="button" (click)="addLink()">Add Link</button>

Select Placeholders

Use a disabled <option> with [ngValue]="null" as the default placeholder. The control is initialized with null and typed as FormControl<string | null>.

country: this.fb.control<string | null>(null, {
  validators: [Validators.required],
}),
<select formControlName="country">
  <option disabled [ngValue]="null">Select your country</option>
  @for (country of countries; track country) {
    <option [ngValue]="country.value">{{ country.label }}</option>
  }
</select>

The disabled placeholder cannot be re-selected once the user picks a value. Validators.required rejects null, enforcing a selection.


Radio Buttons

Multiple <input type="radio"> elements share the same formControlName. The value attribute determines what gets written to the control.

primaryContact: this.fb.control(''),
<label>Primary contact:</label>
<div class="form-check">
  <input type="radio" formControlName="primaryContact" value="email" id="primary-contact-1" />
  <label for="primary-contact-1">Email</label>
</div>
<div class="form-check">
  <input type="radio" formControlName="primaryContact" value="phone" id="primary-contact-2" />
  <label for="primary-contact-2">Phone</label>
</div>

Custom Validators

URL Validator

Uses the native URL constructor for validation.

export function urlValidator(control: AbstractControl): ValidationErrors | null {
  const value = control.value;
  if (!value) return null;
  try {
    new URL(value);
    return null;
  } catch {
    return { invalidUrl: true };
  }
}

Pattern Validator with Custom Error Key

Wraps Validators.pattern but returns a domain-specific error key instead of the generic pattern error.

patternValidator(pattern: string | RegExp, errorName: string): ValidatorFn {
  const patternValidator = Validators.pattern(pattern);
  return (control: AbstractControl): ValidationErrors | null => {
    const validationResult = patternValidator(control);
    if (validationResult) {
      return { [errorName]: true };
    }
    return null;
  };
},

Combined Validators

isEqualWith compares two AbstractControl values. Applied as a group-level validator so it has access to both controls.

isEqualWith(control: AbstractControl, compareTo: AbstractControl) {
  return (): ValidationErrors | null => {
    if (control.value !== compareTo.value) {
      return { isEqualWith: true };
    }
    return null;
  };
},

Applied on the user group:

user: this.fb.group<UserForm>(
  {
    password: this.fb.control('', { validators: [Validators.required] }),
    passwordConfirm: this.fb.control('', { validators: [Validators.required] }),
  },
  {
    validators: [
      (group) => {
        const password = group.get('password');
        const passwordConfirm = group.get('passwordConfirm');
        if (password && passwordConfirm) {
          return CustomValidators.isEqualWith(password, passwordConfirm)();
        }
        return null;
      },
    ],
  },
),

The group-level error is displayed with a formName-less <app-error-message> that targets the group itself:

<!-- Control-level error -->
<app-error-message formName="passwordConfirm"></app-error-message>
<!-- Group-level error (isEqualWith) -->
<app-error-message></app-error-message>

Composed Validators

passwordStrength composes multiple patternValidators with Validators.compose. Each sub-validator returns its own error key, enabling granular error messages.

passwordStrength(config: { minLength: number }): ValidatorFn {
  return Validators.compose([
    Validators.minLength(config.minLength),
    CustomValidators.patternValidator(/[A-Z]/, 'containsUppercase'),
    CustomValidators.patternValidator(/[a-z]/, 'containsLowercase'),
    CustomValidators.patternValidator(/[0-9]/, 'containsNumbers'),
    CustomValidators.patternValidator(/(?=.*\W)/, 'containsSpecialCharacters'),
  ]) as ValidatorFn;
},

Async Validators

BackendService.usernameValidator() returns an AsyncValidatorFn that checks against existing usernames.

@Injectable({ providedIn: 'root' })
export class BackendService {
  checkIfUsernameExists(username: string): Observable<boolean> {
    return of(this.existingUsernames.includes(username)).pipe(delay(1000));
  }

  usernameValidator(): AsyncValidatorFn {
    return (control: AbstractControl): Observable<ValidationErrors | null> => {
      return this.checkIfUsernameExists(control.value).pipe(
        map((res) => (res ? { usernameExists: true } : null)),
      );
    };
  }
}

Applied with updateOn: 'blur' to avoid firing on every keystroke:

username: this.fb.control('', {
  validators: [Validators.required],
  asyncValidators: [this.backendService.usernameValidator()],
  updateOn: 'blur',
}),

Conditional Validators

validateWhen applies a validator only when a sibling control meets a condition. It subscribes to the sibling's valueChanges to trigger re-validation, using a subscribed flag to prevent duplicate subscriptions.

export function validateWhen(
  conditionalFieldName: string,
  whenCondition: (context: AbstractControl<any>) => boolean,
  validator: ValidatorFn,
): ValidatorFn {
  let subscribed = false;
  return (formControl) => {
    if (!formControl.parent) return null;

    const conditionalField = formControl.parent.get(conditionalFieldName);

    if (conditionalField) {
      if (!subscribed) {
        subscribed = true;
        conditionalField.valueChanges.subscribe(() => {
          formControl.updateValueAndValidity();
        });
      }
      if (whenCondition(conditionalField)) {
        return validator(formControl);
      }
    }
    return null;
  };
}

Usage — phone is required only when primaryContact equals "phone":

phone: this.fb.control('', {
  validators: [
    validateWhen(
      'primaryContact',
      (context) => context.value === 'phone',
      Validators.required,
    ),
  ],
}),

Reusable Error Message Component

ErrorMessageComponent resolves its target control via ControlContainer and an optional formName input signal. It subscribes to statusChanges and valueChanges to trigger change detection in an OnPush tree, using takeUntilDestroyed for automatic cleanup.

@Component({
  selector: 'app-error-message',
  templateUrl: './error-message.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: { class: 'invalid-feedback' },
})
export class ErrorMessageComponent implements OnInit {
  private readonly controlContainer = inject(ControlContainer);
  private readonly changeDetectorRef = inject(ChangeDetectorRef);
  private readonly destroyRef = inject(DestroyRef);

  form!: FormGroup;
  formName = input<string | null>(null);
  currentControl!: AbstractControl;

  ngOnInit(): void {
    this.form = this.controlContainer.control as FormGroup;
    const name = this.formName();
    this.currentControl = (name && this.form.get(name)) || this.form;

    merge(this.currentControl.statusChanges, this.currentControl.valueChanges)
      .pipe(
        startWith(null),
        distinctUntilChanged(),
        takeUntilDestroyed(this.destroyRef),
      )
      .subscribe(() => {
        this.changeDetectorRef.detectChanges();
      });
  }
}

When formName is omitted, the component targets the parent FormGroup itself — useful for group-level validators like isEqualWith:

<!-- Control-level errors -->
<app-error-message formName="password"></app-error-message>

<!-- Group-level errors (targets the parent FormGroup) -->
<app-error-message></app-error-message>

Auto-Required Attribute Directive

Automatically sets the native required attribute on any element that has Validators.required attached. No manual required attribute needed in templates.

@Directive({
  selector: '[formControl], [formControlName]',
})
export class FormControlRequiredAttributeDirective implements OnInit {
  private readonly elementRef = inject(ElementRef);
  private readonly ngControl = inject(NgControl);

  ngOnInit(): void {
    if (
      (this.ngControl instanceof FormControlName ||
        this.ngControl instanceof FormControlDirective) &&
      this.ngControl.control.hasValidator(Validators.required)
    ) {
      this.elementRef.nativeElement.required = true;
    }
  }
}

Import it in each component's imports array — it applies to all [formControl] and [formControlName] elements in that template.

About

Created with StackBlitz ⚡️

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors