Fanis Prodromou
I am a Senior Software Engineer with a passion for Front End development with Angular. I have developed vast experience in code quality, application architecture, and application performance.
Fanis Prodromou
Code. Teach. Community. Angular.
https://blog.profanis.me
/prodromouf
@prodromouf
Where does state live?
Where does state live?
Global State
horizontal state of the app
Component
information of that particular component
URL State
highest level of state.
(output)outputHandler : () => voidemit filters
Filters
Results
http call
HTTP
emit filters
Filters
Results
http call
HTTP
user should start from beginning
Refresh page
unable to share or bookmark a page
Deep linking
Broken back button
results component is tight coupled with filters component
Tight coupling
emit filters
read params
Filters
Results
URL
prepare url params
navigate with params
http call
HTTP
emit filters
read params
Filters
Results
URL
prepare url params
navigate with params
http call
HTTP
emit filters
read params
Filters
Results
URL
prepare url params
navigate with params
http call
HTTP
emit filters
read params
Filters
Results
URL
prepare url params
navigate with params
http call
HTTP
emit filters
read params
Filters
Results
URL
prepare url params
navigate with params
http call
HTTP
Refresh page
user is able to refresh the page
Deep linking
user is able to share or bookmark a page
Back button works as expected
URL should be the single source of truth
// filters.component.ts
readonly filterForm: FormGroup = this.fb.group({
filterOne: this.fb.control<boolean[]>([]),
filterTwo: this.fb.control<string | null>(null),
filterThree: this.fb.control<string | null>(null),
});
constructor() {
this.filterForm.valueChanges.subscribe((value) => {
// map the form values
// emit an event
});
}The filters
// filters.component.ts
effect(() => {
this.populateFormFromURL();
});
private populateFormFromURL(): void {
// Get the URL params
getUrlParams();
// Apply a mapping on each individual item and prepare the form value
mapUrlValuesToSpecificFilter();
// Final form value data
const formValue = {
filterOne: filterOneValue,
filterTwo: filterTwoValue,
filterThree: filterThreeValue,
};
this.filterForm.setValue(formValue, { emitEvent: false });
}When we reload the page - Populate the filters
// filters.component.ts
readonly hasActiveFilters = computed(() => {
const formValue = this.formValues();
if (!formValue) {
return false;
}
const filterOne = boolean expression;
const filterTwo = boolean expression;
const filterThree = boolean expression;
return filterOne || filterTwo || filterThree;
});
}Display the clear all filters
// pills.component.ts
private readonly queryParams = toSignal(this.route.queryParams);
private readonly filterState = mapUrlValuesToModel(); // prepare state out of URL
readonly chips = computed<SearchChip[]>(() => {
const state = this.filterState();
const result: SearchChip[] = [];
// Loop over the selected checkbox items
for (const value of state.filterOneValue) {
result.push({
key: 'filterOne', label: `filterOne:${value}`,
});
}
// Handle the single select items (radio or select menus)
if (state.filterTwoValue) {
result.push({
key: 'filterTwo',
label: `filterTwo:${state.filterTwoValue}`,
});
}
return result;
});Display the pills
For every filter....
filterForm formControlNameemitFilterState() method to serialize the new filterpopulateFormURL() to populate from URL paramsclearFilters() to reset the new filterhasActiveFilters() computed to check the new filter valueremoveChip() switch statementAtomic State Controllers
What is a controller?
M
V
C
M
V
C
What the data is
What the user sees
Is the brain that coordinates the Model & View
M
V
C
What the data is
What the user sees
Is the brain that coordinates the Model & View
Data
1. list of options
Data
1. list of options
2. selected values
Data
1. list of options
2. selected values
3. hasFilters flag
Data
1. list of options
2. selected values
3. hasFilters flag
4. pills
Data
1. list of options
2. selected values
3. hasFilters flag
4. pills
Methods
Data
1. list of options
2. selected values
3. hasFilters flag
4. pills
Methods
1. apply filter
Data
1. list of options
2. selected values
3. hasFilters flag
4. pills
Methods
1. apply filter
2. reset filters
Data
1. list of options
2. selected values
3. hasFilters flag
4. pills
Methods
1. apply filter
2. reset filters
3. remove filter
Data
1. list of options
2. selected values
3. hasFilters flag
4. pills
Methods
1. apply filter
2. reset filters
3. remove filter
A "controller" is just a fancy name for:
A function that manages some state and behavior
export function atomicFilterController() {
return {
data: {
},
methods: {
}
}
}A "controller" is just a fancy name for:
A function that manages some state and behavior
export function atomicFilterController() {
return {
data: {
options,
selectedOptions,
pills,
hasFilters
},
methods: {
}
}
}A "controller" is just a fancy name for:
A function that manages some state and behavior
export function atomicFilterController() {
return {
data: {
options,
selectedOptions,
pills,
hasFilters
},
methods: {
applyFilter,
removeFilter,
resetFilter
}
}
}A "controller" is just a fancy name for:
A function that manages some state and behavior
export function atomicFilterController() {
const selectedOptions = signal(null);
const pills = computed(() => /* derive pills */);
const applyFilter = () => { /* logic */ };
const removeFilter = () => { selectedOptions.set(null); };
return {
data: {
options,
selectedOptions,
pills,
hasFilters
},
methods: {
applyFilter,
removeFilter,
resetFilter
}
}
}export function atomicFilterController() {
const router = inject(Router);
const selectedOptions = signal(null);
const pills = computed(() => /* derive pills */);
const applyFilter = () => { /* logic */ };
const removeFilter = () => { selectedOptions.set(null); };
return {
data: {
options,
selectedOptions,
pills,
hasFilters
},
methods: {
applyFilter,
removeFilter,
resetFilter
}
}
}export function atomicFilterController(opts: {...}) {
const router = inject(Router);
const selectedOptions = signal(null);
const pills = computed(() => /* derive pills */);
const applyFilter = () => { /* logic */ };
const removeFilter = () => { selectedOptions.set(null); };
return {
data: {
options,
selectedOptions,
pills,
hasFilters
},
methods: {
applyFilter,
removeFilter,
resetFilter
}
}
}export function atomicFilterController(opts: {...}) {
const router = inject(Router);
const selectedOptions = signal(null);
const pills = computed(() => /* derive pills */);
const applyFilter = () => { /* logic */ };
const removeFilter = () => { selectedOptions.set(null); };
return {
data: {
options: opts.options,
selectedOptions,
pills,
hasFilters
},
methods: {
applyFilter,
removeFilter,
resetFilter
}
}
}export function atomicFilterController(opts: {...}) {
const router = inject(Router);
const selectedOptions = signal(null);
const pills = computed(() => /* derive pills */);
const applyFilter = () => {
/* logic */
opts.applyFilterHook();
};
const removeFilter = () => { selectedOptions.set(null); };
return {
data: {
options: opts.options,
selectedOptions,
pills,
hasFilters
},
methods: {
applyFilter,
removeFilter,
resetFilter
}
}
}How can I use this?
// filters.component.ts
plantType = atomicFilterController({
controllerName: 'plantTypeFilter', // Unique ID
});// filters.component.ts
plantType = atomicFilterController({
controllerName: 'plantTypeFilter', // Unique ID
options: signal(['Indoor', 'Outdoor']),
});// filters.component.ts
plantType = atomicFilterController({
controllerName: 'plantTypeFilter', // Unique ID
options: signal(['Indoor', 'Outdoor']),
selectedValue: computed(() => this.urlParams().plantType),
});// filters.component.ts
plantType = atomicFilterController({
controllerName: 'plantTypeFilter', // Unique ID
options: signal(['Indoor', 'Outdoor']),
selectedValue: computed(() => this.urlParams().plantType),
methods: {
applyFilterHook: (value) => {
this.router.navigate([], { queryParams: { plantType: value } });
this.analytics.track('filter_applied', { filter: 'plantType', value });
},
}
});// filters.component.ts
plantType = atomicFilterController({
controllerName: 'plantTypeFilter', // Unique ID
options: signal(['Indoor', 'Outdoor']),
selectedValue: computed(() => this.urlParams().plantType),
methods: {
applyFilterHook: (value) => {
this.router.navigate([], { queryParams: { plantType: value } });
this.analytics.track('filter_applied', { filter: 'plantType', value });
},
resetFilterHook: () => {
this.router.navigate([], { queryParams: { plantType: null } });
},
}
});// filters.component.ts
plantType = atomicFilterController({
controllerName: 'plantTypeFilter', // Unique ID
options: signal(['Indoor', 'Outdoor']),
selectedValue: computed(() => this.urlParams().plantType),
methods: {
applyFilterHook: (value) => {
this.router.navigate([], { queryParams: { plantType: value } });
this.analytics.track('filter_applied', { filter: 'plantType', value });
},
resetFilterHook: () => {
this.router.navigate([], { queryParams: { plantType: null } });
},
removeFilterHook: () => {
// do something here
}
}
});How about the HTML template?
<app-radio-filter
[options]="plantTypeOptions"
[selectedValue]="selectedPlantType"
(filterChange)="plantTypeChange($event)"
(filterReset)="plantTypeReset()"
(filterRemove)="plantTypeRemove()"
/><app-radio-filter [controller]="plantType" /><!-- Same component, different behavior! -->
<app-radio-filter [controller]="plantType" />
<app-radio-filter [controller]="otherFilterType" />
<app-radio-filter [controller]="oneMoreFilterType" />Each Controller has different
Options
Selected Values
Side Effects (URL vs. service vs. local)
tvRemote = remoteController({
deviceId: 'living-room-tv'
});tvRemote = remoteController({
deviceId: 'living-room-tv',
channels: ['HBO', 'Netflix', 'YouTube'],
});tvRemote = remoteController({
deviceId: 'living-room-tv',
channels: ['HBO', 'Netflix', 'YouTube'],
currentChannel: computed(() => this.tvService.currentChannel()),
});tvRemote = remoteController({
deviceId: 'living-room-tv',
channels: ['HBO', 'Netflix', 'YouTube'],
currentChannel: computed(() => this.tvService.currentChannel()),
methods: {
changeChannel: (channel) => this.tvService.tune(channel),
turnOff: () => this.tvService.powerOff()
}
});Controller One
Controller Three
Controller Two
Controller X
data
methods
data
methods
data
methods
data
methods
[...data]
[...methods]
Wrapper Controller speaks to ALL
using the SAME interface
Data
1. hasFilters flag
Data
1. hasFilters flag
2. pills
Data
1. hasFilters flag
2. pills
Methods
Data
1. hasFilters flag
2. pills
Methods
1. reset filter
Data
1. hasFilters flag
2. pills
Methods
1. reset filter
export function atomicWrapperController() {
return {
data: {
},
methods: {
}
}
}export function atomicWrapperController() {
return {
data: {
pills,
hasFilters
},
methods: {
resetFilter
}
}
}export function atomicWrapperController() {
const controllers = [];
return {
data: {
pills,
hasFilters
},
methods: {
resetFilter,
register: (controller: any) => {
controllers.push(controller);
},
}
}
}export function atomicWrapperController() {
const controllers = [];
const pills = computed(() =>
controllers.reduce((acc, ctrl) => {
const pills = ctrl.data.pills() || [];
return [...acc, ...pills];
}, []),
);
return {
data: {
pills,
hasFilters
},
methods: {
resetFilter,
register: (controller: any) => {
controllers.push(controller);
},
}
}
}export function atomicWrapperController() {
const controllers = [];
const pills = computed(() =>
controllers.reduce((acc, ctrl) => {
const pills = ctrl.data.pills() || [];
return [...acc, ...pills];
}, []),
);
const resetFilter = () => {
controllers.forEach((ctrl) => {
ctrl.methods.removeFilter();
});
}
return {
data: {
pills,
hasFilters
},
methods: {
resetFilter,
register: (controller: any) => {
controllers.push(controller);
},
}
}
}How can we use dat?
// filters.component.ts
readonly wrapperController = atomicWrapperController();
filterOne = atomicFilterController({
controllerName: 'plantType',
wrapperController: this.wrapperController,
...
});// filters.component.ts
clearFilters(): void {
this.wrapperController.methods.removeFilter();
}// pills.component.ts
private readonly queryParams = toSignal(this.route.queryParams);
private readonly filterState = mapUrlValuesToModel(); // prepare state out of URL
readonly chips = computed<SearchChip[]>(() => {
const state = this.filterState();
const result: SearchChip[] = [];
// Loop over the selected checkbox items
for (const value of state.filterOneValue) {
result.push({
key: 'filterOne', label: `filterOne:${value}`,
});
}
// Handle the single select items (radio or select menus)
if (state.filterTwoValue) {
result.push({
key: 'filterTwo',
label: `filterTwo:${state.filterTwoValue}`,
});
}
return result;
});Display the pills
// pills.component.ts
readonly chips = this.wrapperController.data.pills();Display the pills
For every filter....
In Summary
Automatic Coordination
Single Responsibility
Reusability
Maintainability
Reduced Cognitive Load
Thank you
Code. Teach. Community. Angular.
https://blog.profanis.me
/prodromouf
@prodromouf
By Fanis Prodromou
Angular Signals: A Look Under the Hood and Beyond
I am a Senior Software Engineer with a passion for Front End development with Angular. I have developed vast experience in code quality, application architecture, and application performance.