Angular: a declarative dynamic button-cascade
# Imperative vs Declarative
in each function one can acces the classMembers and change them, so the dev has to follow all the steps carefully to understand the flow
# Imperative vs Declarative
when one uses myData, in template will trigger change detection, so accessing this later in functions will not show all functionality in a glance
# Imperative vs Declarative
define all behavior in streams and to not reassign
example:
the result of someThingOne can later be used in someThingFour
# Imperative vs Declarative
no need to define timing
myData -> get data
filteredData -> transform data
# The Format: Show and Tell
a standard web dev AC from our customer and we want to implement this declarative so we use Angular reactive Forms Module
i wanted to share my experience of developing this feature to showcase the pitfalls and to show you this, i mvp'd the example into a stackblitz
https://stackblitz.com/~/github.com/janpauldahlke/ccwt-example
# real world AC
Prerequisites:
State of the form:
// in src/app/pokemon/filter/services/pokemon.service.ts
// simulate the behavior of the endpoint here
//active pokemones by admin
randomPokemonNumbers = [240, 146, 401]
// selected pokemon by user
couldBeHttpFilter: PokemonFilter = {
allSelected: false,
pokemon: [
{
name: 'foo',
id: 240
},
]
}
How to simulate enpdpoint in the example?
pokemon/filter.component.ts
pokemon/filter.component.ts
reactive Form setup // initializing
form$ = this.formBuilder.nonNullable.group({
allSelected: this.formBuilder.nonNullable.control(false),
pokemons: this.formBuilder.nonNullable.group({}),
})And observe the form changes in ngOnInit
ngOnInit() {
this.form.valueChanges
.pipe(
//some methods here
)
.subscribbe()
}pokemon/filter.component.ts
how to data?
filterSettings$ : Observable<PokemonFilter> = this.pokemonService.getPokemonFilterSettings();
pokemonsHttp$: Observable<Pokemon[]> = this.pokemonService.getPokemons();
pokemons$: Observable<Pokemon[]> = combineLatest({
pokemonInfo: this.pokemonsHttp$,
filters: this.filterSettings$
})
.pipe(
//more methods
)pokemon/filter.component.ts
adding dynamic controls to form
tap(({pokemonInfo, filters}) => {
this.pokemonInfo = pokemonInfo;
const pokemonsControls =
// create object, where PokeId = FormControl
pokemonInfo.reduce((acc: { [key: string]: FormControl }, pokemon: Pokemon) => {
const isSelected =
filters.pokemon.some(filterPokemon => filterPokemon.id === pokemon.id);
acc[pokemon.id.toString()] = this.formBuilder.control(isSelected);
return acc;
}, {});
// set this in the empty pokemons fb.group
this.form$.setControl('pokemons', this.formBuilder.group(pokemonsControls));
this.form$.get('allSelected')?.setValue(filters.allSelected, {emitEvent: false});
this.indeterminate = !filters.allSelected && filters.pokemon.length > 0;
}),listenToPokemonSelections() {
const pokemonsGroup = this.form$.controls.pokemons as FormGroup;
Object.values(pokemonsGroup.controls).forEach((control) => {
const formControl = control as FormControl;
// we create a subscription to each pokemonControl here
// care to remove those correct
// in TAS are other techniques for this
const subscription= formControl.valueChanges.subscribe((poke) => {
const allSelected = Object.values(pokemonsGroup.controls).every(c => c.value);
const someSelected = Object.values(pokemonsGroup.controls).some(c => c.value);
this.form$.controls.allSelected.setValue(allSelected, {emitEvent: false});
this.indeterminate = someSelected && !allSelected;
});
this.nestedFormSubscriptions.add(subscription);
});
}updating the state of 'allSelected' from the singles
* will be called in ngOnInit
pokemon/filter.component.ts
pokemon/filter.component.ts
updating the state pokemnes from 'allSelected'
toggleAll() {
const pokemonsControls = this.form$.controls.pokemons;
const toggle = this.form$.controls.allSelected.value!;
const pokemonUpdates = Object.keys(pokemonsControls.controls).reduce((acc, key) => ({
...acc,
[key]: toggle
}), {});
this.form$.controls.pokemons.patchValue(pokemonUpdates);
}//inside the formValueChanges
if (changes.allSelected !== this.savedAllSelected) {
this.toggleAll();
this.savedAllSelected = changes.allSelected ? changes.allSelected : false
}Problems for the developer
* component sometimes feels flaky and slow
* dynamic controls can not be accessed in ngOnit! (show old commit)
* a are observing the very form we apply changes to
* this could lead infinite recursion
* we need techniques to stop it
hands on // tinker around
in the blitz:
https://stackblitz.com/~/github.com/janpauldahlke/ccwt-example
* distinctUntilChanged (comment in and out)
* log valueChanges
* remove the memoize if in changes (comment in and out)
ways to gain control
* debounceTime() // controlling how often the check occurs
* distinctUntilChanged. works partially, it is shallow compare,
* deepEqual is aways expensive, when the form is large and is considered to a be not "best practice"
* when setting the formControls. we might need to manually trigger changeDetection, since structrual changes do not get recon by angular
We opted to mix imperative and declarative to achieve our AC.
* like '[indeterminate]="indeterminate"' in template using the reassign
Questions while developing
1. are there better way's to implement, can we get rid of
* distinctUntilChanged
... and the other thing we used
2. is reative Forms module the ideal candidate for such usecase ?
* could ngModel help out?
what are your thoughts, let's talk ... :-)