LiveLoveApp logo

Component Store and FormControl Sync

Published by Brian Love on
Component Store and FormControl Sync
Component Store and FormControl Sync

In this article you'll learn:

  • How to sync data from NgRx Component Store into an Angular FormControl instance.
  • How to sync data from an Angular FormControl instance into NgRx Component Store.
  • Perhaps a bit about AG Grid.

Demo App

The GridComponent

In this example, we'll use NgRx Component Store for managing the state of the GridComponent, a component that uses AG Grid to display a bunch of data. We'll pretend that we have a bunch of Tesla Model S Plaid vehicles for sale (at random prices, because that's how you would optimally sell expensive cars).

Note:

  • The component includes a page input that is an instance of a FormControl.
  • We want to set the value of the page form control from the value of the page property that is stored in the Component Store, our source of truth.
  • When a user changes the form control value, we want to set the page property value in the component store.

Here is the example GridComponent.

@Component({
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    // omitted for brevity
  ],
  providers: [GridStore],
  template: `
    <div class="controls">
      <mat-form-field>
        <mat-label>Page</mat-label>
        <input matInput [formControl]="page" />
      </mat-form-field>
    </div>
    <ag-grid-angular
      class="ag-theme-material"
      (gridReady)="onGridReady($event)"
      [columnDefs]="columnDefs"
      [defaultColDef]="defaultColDef"
      [pagination]="true"
      [paginationPageSize]="100"
      [rowData]="rowData | async"
      [suppressPaginationPanel]="true"
    ></ag-grid-angular>
  `
})
export class GridComponent implements OnInit {
  columnDefs = [
    {
      headerName: 'Model',
      field: 'name',
      cellRenderer: NameCellRenderer,
      cellRendererParams: {
        click: ({ data }) => window.alert(`You clicked: ${data.name}`),
        document: this.document,
        isAbbreviated: () => true,
      } as NameCellRendererParams<RowData>,
    },
    { headerName: 'Price', field: 'price' },
    {
      headerName: 'Tax',
      valueGetter: multiplierValueGetter<RowData>('price', 'taxRate'),
      valueFormatter: decimalValueFormatter<RowData>(2),
    },
  ] as ColDef<RowData>[];

  defaultColDef = {
    sortable: true,
    filter: true,
  } as ColDef;

  page = new FormControl(0);

  rowData: Observable<RowData[]> = of(
    Array(9_000)
      .fill({})
      .map(() => ({
        id: uuid(),
        name: 'Model S Plaid',
        price: Math.round(Math.random() * (150_000 - 100_000) + 100_000),
        taxRate: 0.06,
      }))
  );

  constructor(
    @Inject(DOCUMENT) private readonly document: Document,
    private readonly gridStore: GridStore
  ) {}

  ngOnInit(): void {
    this.gridStore.syncPage(this.page);
  }

  onGridReady({ api }: GridReadyEvent) {
    this.gridStore.api = api;
  }
}

Let's review the code above:

  • First, we are using an Angular standalone component (version 14+).
  • Next, in the template we bind the input element to the page form control: [formControl]="page".
  • The component also includes the <ag-grid-angular> component for AG Grid.
  • Next, we wire up the necessary columnDefs, defaultColDef, and rowData that is necessary for AG Grid.
  • Note that the page property in our class is a new FormControl instance with the initial value set to 0.
  • Finally, note that we invoke the syncPage() method in the ngOnInit() lifecycle method providing a reference to the page FormControl instance.

Component Store

With the GridComponent defined, the next step is to create a new GridComponentStore.

interface GridState {
  page: number;
}

const initialState = {
  page: 1,
} as GridState;

@Injectable()
class GridStore extends ComponentStore<GridState> {
  /** The current pagination page. */
  page$ = this.select((s) => s.page);

  /** The AG Grid API. */
  api?: GridApi;

  /** Apply the pagination page to the AG Grid Grid instance. */
  private readonly applyPaginationPage = this.effect(
    (page: Observable<number>) => {
      return page.pipe(
        tapResponse(
          (page: number) => {
            if (this.api) {
              this.api.paginationGoToPage(page - 1);
            }
          },
          (error) => {
            console.error('Error applying pagination page', error);
          }
        )
      );
    }
  );

  /** Sync values between component state page prop and form control. */
  readonly syncPage = this.effect((formControl$: Observable<FormControl>) => {
    return formControl$.pipe(
      switchMap((formControl) => {
        const syncValuesFromControl$ = formControl.valueChanges.pipe(
          tapResponse(
            (value) => {
              this.setPage(value);
            },
            (error) => {
              console.error('Error syncing page', error);
            }
          )
        );

        const syncValuesToControl$ = this.page$.pipe(
          tap((page) => {
            formControl.setValue(page, { emitEvent: false });
          })
        );

        return merge(syncValuesFromControl$, syncValuesToControl$);
      })
    );
  });

  constructor() {
    super(initialState);
    this.applyPaginationPage(this.page$);
  }

  setPage(page: number): void {
    this.setState((state) => ({
      ...state,
      page,
    }));
  }
}

Let's review the code above:

  • First, we declare the GridState interface and the initialState object that implements the interface.
  • Our GridComponentStore extends the ComponentStore class, providing the GridState generic to inform the shape of our component store state.
  • The applyPaginationPage() effect will apply the current page state property value to the AG Grid instance using the Grid API's paginationGoToPage() method.
  • syncPage effect does the heavy lifting of synchronizing the component store state with the FormControl in the UI.
  • The syncPage() effect is invoked with the FormControl instance.
  • Next, the syncPage() effect uses the tapResponse() operator (from NgRx) invoke the setPage() method in the component store when the valueChanges() Observable emits the value from the form control.
  • Also within the tapResponse() operator, the effect invokes the setValue() method on the FormControl instance to set the value of the form control when the page property value is updated in the component store.
  • In the constructor() function we invoke the applyPaginationPage() effect.
  • Finally, in the setPage() method we update the page property in the component store state.

Conclusion

Synchronizing state from NgRx Component Store with Angular's forms API enables us to leverage component store for managing the (potentially complex) state of a component. One approach is to use a side-effect that is provided with the FormControl instance. You could also use a similar approach with Angular's FormGroup class.

Design, Develop and Deliver Absolute Joy
Schedule a Call