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 aFormControl
. - We want to set the value of the
page
form control from the value of thepage
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
, androwData
that is necessary for AG Grid. - Note that the
page
property in our class is a newFormControl
instance with the initial value set to0
. - Finally, note that we invoke the
syncPage()
method in thengOnInit()
lifecycle method providing a reference to thepage
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 theinitialState
object that implements the interface. - Our
GridComponentStore
extends theComponentStore
class, providing theGridState
generic to inform the shape of our component store state. - The
applyPaginationPage()
effect will apply the currentpage
state property value to the AG Grid instance using the Grid API'spaginationGoToPage()
method. syncPage
effect does the heavy lifting of synchronizing the component store state with theFormControl
in the UI.- The
syncPage()
effect is invoked with theFormControl
instance. - Next, the
syncPage()
effect uses thetapResponse()
operator (from NgRx) invoke thesetPage()
method in the component store when thevalueChanges()
Observable emits the value from the form control. - Also within the
tapResponse()
operator, the effect invokes thesetValue()
method on theFormControl
instance to set the value of the form control when thepage
property value is updated in the component store. - In the
constructor()
function we invoke theapplyPaginationPage()
effect. - Finally, in the
setPage()
method we update thepage
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.