Question

Using signals with dynamic angular forms?

I have this shared component which builds a form based on the SearchFilterOption structure.

Here it is (simplified):

import {
    Component,
    EventEmitter,
    inject,
    Input,
    OnChanges,
    Output,
    SimpleChanges,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, Subscription } from 'rxjs';

export type SearchFilterOption = {
    kind: 'text' | 'search' | 'checkbox';
    label: string;
    key: string;
};

@Component({
    selector: 'shared-search-filter',
    standalone: true,
    imports: [CommonModule, ReactiveFormsModule],
    templateUrl: './search-filter.component.html',
})
export class SearchFilterComponent implements OnChanges {
    @Input() filter: { [key: string]: any } = {};
    @Input() filterOptions: readonly SearchFilterOption[] = [];
    @Output() filterChange = new EventEmitter<{ [key: string]: any }>();

    form = new FormGroup({});
    formChange?: Subscription;

    ngOnChanges(changes: SimpleChanges): void {
        if (changes['filterOptions']) {
            const group: { [key: string]: FormControl } = {};
            for (const filter of this.filterOptions) {
                group[filter.key] = new FormControl();
            }
            this.form = new FormGroup(group);
            this.formChange?.unsubscribe();
            this.formChange = this.form.valueChanges
                .pipe(
                    debounceTime(500),
                    distinctUntilChanged(
                        (a, b) => JSON.stringify(a) === JSON.stringify(b)
                    )
                )
                .subscribe({
                    next: (values) => {
                        this.filterChange.emit(values);
                    },
                });
        }
        if (changes['filter']) {
            for (const key of Object.keys(this.filter)) {
                const ctrl = this.form.get(key);
                if (ctrl) {
                    ctrl.setValue(this.filter[key]);
                }
            }
        }
    }
}
<fieldset [formGroup]="form">
    @for (option of filterOptions; track option; let i = $index) {
        <label>{{ option.label }}</label>
        <input [type]="option.kind" [formControlName]="option.key" />
    }
</fieldset>

and I wanted to try using signals for the input and output of this component, but it all seems to fall apart with errors of missing form fields when I do:

    filter = model<{ [key: string]: any }>({});
    filterOptions = input.required<readonly SearchFilterOption[]>();

    form = new FormGroup({});
    formChange?: Subscription;

    constructor() {
        effect(
            () => {
                const group: { [key: string]: FormControl } = {};
                for (const filter of this.filterOptions()) {
                    group[filter.key] = new FormControl(
                        this.filter()[filter.key]
                    );
                }
                this.form = new FormGroup(group);

                this.formChange?.unsubscribe();
                this.formChange = this.form.valueChanges
                    .pipe(
                        debounceTime(500),
                        distinctUntilChanged(
                            (a, b) => JSON.stringify(a) === JSON.stringify(b)
                        )
                    )
                    .subscribe({
                        next: (values) => {
                            this.filter.set(values);
                        },
                    });
            },
            { allowSignalWrites: true }
        );
    }

I've also tried a variation where the subscription uses toSignal, and one that uses computed to build the form and can't really figure out what else to do.

Is there a working method of doing this with signals right now?

 2  58  2
1 Jan 1970

Solution

 1

Note: for now this is the best approach I can think of, but when reactive form signals arrives, this code will be redundant.


Your code works fine, all you need to do is to move the for loop that creates the form controls to the ngOnInit.

import {
  Component,
  effect,
  EventEmitter,
  inject,
  input,
  Input,
  model,
  OnChanges,
  Output,
  SimpleChanges,
  untracked,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { debounceTime, distinctUntilChanged, Subscription, tap } from 'rxjs';
import { toSignal, outputFromObservable } from '@angular/core/rxjs-interop';

export type SearchFilterOption = {
  kind: 'text' | 'search' | 'checkbox';
  label: string;
  key: string;
};

@Component({
  selector: 'shared-search-filter',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  template: `<fieldset [formGroup]="form">
  @for (option of filterOptions; track option; let i = $index) {
      <label>{{ option.label }}</label>
      <input [type]="option.kind" [formControlName]="option.key" />
  }
</fieldset>`,
})
export class SearchFilterComponent implements OnChanges {
  @Input() filter: { [key: string]: any } = {};
  @Input() filterOptions: readonly SearchFilterOption[] = [];
  @Output() filterChange = new EventEmitter<{ [key: string]: any }>();

  form = new FormGroup({});
  formChange?: Subscription;

  ngOnChanges(changes: SimpleChanges): void {
    if (changes['filterOptions']) {
      const group: { [key: string]: FormControl } = {};
      for (const filter of this.filterOptions) {
        group[filter.key] = new FormControl();
      }
      this.form = new FormGroup(group);
      this.formChange?.unsubscribe();
      this.formChange = this.form.valueChanges
        .pipe(
          debounceTime(500),
          distinctUntilChanged(
            (a, b) => JSON.stringify(a) === JSON.stringify(b)
          )
        )
        .subscribe({
          next: (values) => {
            this.filterChange.emit(values);
          },
        });
    }
    if (changes['filter']) {
      for (const key of Object.keys(this.filter)) {
        const ctrl = this.form.get(key);
        if (ctrl) {
          ctrl.setValue(this.filter[key]);
        }
      }
    }
  }
}

@Component({
  selector: 'shared-search-filter2',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  template: `<fieldset [formGroup]="form">
  @for (option of filterOptions(); track option; let i = $index) {
      <label>{{ option.label }}</label>
      <input [type]="option.kind" [formControlName]="option.key" />
  }
</fieldset>`,
})
export class SearchFilterComponent2 {
  filter = model<{ [key: string]: any }>({});
  filterOptions = input.required<readonly SearchFilterOption[]>();

  form = new FormGroup({});
  formChange?: Subscription;

  constructor() {
    effect(() => {
      if (this.formChange) {
        this.formChange.unsubscribe();
      }
      this.formChange = this.form.valueChanges
        .pipe(
          debounceTime(500),
          distinctUntilChanged(
            (a, b) => JSON.stringify(a) === JSON.stringify(b)
          ),
          tap(() => {
            untracked(() => {
              this.filter.set(this.form.value);
            });
          })
        )
        .subscribe();
    });
  }

  ngOnInit() {
    const group: { [key: string]: FormControl } = {};
    for (const filter of this.filterOptions()) {
      group[filter.key] = new FormControl(this.filter()[filter.key]);
    }
    this.form = new FormGroup(group);
  }
}

Stackblitz Demo

2024-07-23
Naren Murali

Solution

 1

Without knowing the nature of the errors, all I can do is guess at the solution and recommend some changes to make the component simpler.

  1. I believe the primary issue in the signals version is likely from looping through filterOptions while building the form with effect. The timing when effects run won't necessarily be at the point an input changes. So you need to check to see if the form control exists first or use FormArray and loop through that in the template.
  2. In your view you were looping through filterOptions directly without calling the signal as a function. If this isn't changed in the signal version then it'll break. I actually just cautioned about this in an article - Angular Component Bindings: Transitioning from Decorators to Functions.
  3. Don't recreate the form with every change of filterOptions, just reset it and recreate the controls.
  4. In the form resetting effect access filter with untracked. Otherwise every time it changes then the form has to be remade.
  5. Use a separate effect to update the form values. Yes this might result in a little redundancy, but it will work in a similar fashion to what you had previously.
  6. Instead of using model, split the filter input and output up so you can use the outputFromObservable to emit changes. This way you don't have to maintain any subscriptions.
readonly filter = input<{ [key: string]: any }>({});
readonly filterOptions = input.required<readonly SearchFilterOption[]>();
readonly form = new FormGroup({});
readonly filterChange = outputFromObservable(this.form.valueChanges
  .pipe(
    debounceTime(500),
    distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
  ));
constructor() {
  effect(() => {
    this.form.reset();
    Object.keys(this.form.controls).forEach(key => this.form.removeControl(key));
    const filter = untracked(this.filter); // changes here won't cause an effect.
    for (const opt of this.filterOptions()) {
      this.form.addControl(opt.key, new FormControl(filter[opt.key]));
    }
  });
  effect(() => {
    const filter = this.filter();
    for (const [key, value] of Object.entries(this.filter)) {
      this.form.get(key)?.setValue(value);
    }
  });
}
<fieldset [formGroup]="form">
  <!-- Don't forget to call fitlerOptions since it is a function now. -->
  @for (option of filterOptions(); track option; let i = $index) {
    @if (form.get(option.key); as formCtrl) {
      <label>{{ option.label }}</label>
      <input [type]="option.kind" [formControl]="formCtrl" />
    }
  }
</fieldset>
2024-07-23
Daniel Gimenez