• Google Developer Expert (GDE) in Angular
  • Author of Mastering Angular Reactive Forms
  • Educator & Technical Content Creator
  • Senior Angular Developer  @ ASI
  • Co-organizer of Angular Athens Meetup

Fanis Prodromou

Code. Teach. Community. Angular.

https://blog.profanis.me

/prodromouf

@prodromouf

Atomic State Strategies for Complex Angular UIs

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 : () => void

emit 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....

  1. Add a new FormControl to filterForm 
  2. Update the template with the filter component + formControlName
  3. Update emitFilterState() method to serialize the new filter
  4. Update populateFormURL() to populate from URL params
  5. Update clearFilters() to reset the new filter
  6. Update hasActiveFilters() computed to check the new filter value
  7. Update pills component to display the new filter chip
  8. Update pills component's removeChip() switch statement

Atomic 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 

Let's improve it

Filter Requirements

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....

  1. Inject the appropriate atomic filter controller
  2. Configure it
  3. Add the atomic filter component to the template wired to the new controller

In Summary

Automatic Coordination

Single Responsibility

Reusability

Maintainability

Reduced Cognitive Load

Thank you

Code. Teach. Community. Angular.

https://blog.profanis.me

/prodromouf

@prodromouf