How to manage state in Angular as your application grows



Let's say you are faced with the task of writing a frontend application. There is a technical task with a description of the functionality, tickets in the bug tracker. But the choice of a specific architecture is up to you.



, , . . , ? - , . — .



.



: .



. , , - . HTTP API.



. , . , . , , .



, .







    

    

         :

         :

    

         —

         —



    

     NgRx

         NgRx

         NgRx

         NgRx

         NgRx





, ( app-table) ( app-filters). , . — , ( ), ( ).



, . :



import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common';

@Component({
  selector: 'app-root',
  template: `
      <app-filters (selectedChange)="onFilterChange($event)" [filtersData]="filtersData$ | async"></app-filters>
      <app-table [data]="data$ | async"></app-table>
  `,
})
export class VerySimpleAppComponent implements OnInit {
  data$: Observable<Item[]>;
  filtersData$: Observable<any>;

  constructor(public http: HttpClient) {
    this.data$ = this.http.get<Item[]>('/api/data');
    this.filtersData$ = this.http.get<any>('/api/filtersData');
  }

  onFilterChange(filterValue: any) {
    this.data = this.filterData(filterValue);
  }

  private filterData(filterValue): Item[] {
    //   
  }
}






: . , . :



@Component({
  selector: 'app-root',
  template: `
    <app-filters (selectedChange)="onFilterChange($event)" [filtersData]="filtersData"></app-filters>
    <router-outlet></router-outlet> <!--       -->
  `,
})
export class AppComponent {
}


. .

, -? , - . layout :



@Component({
  selector: 'app-root',
  template: `
    <router-outlet></router-outlet> <!--         -->
  `,
})
export class AppComponent {
}

//   
@Component({
  selector: 'app-first-page',
  template: `
    <!--   -->
    <app-filters-common (selectedChange)="onFilterChange($event)" [filtersData]="filtersData"></app-filters-common> 

    <!-- ,     -->
    <app-filters-for-first-table><app-filters-for-first-table>

    <app-table-first [data]="data"></app-table-first>
  `,
})
export class FirstPageComponent {}

@Component({
  selector: 'app-second-page',
  template: `
    <!--   -->
    <app-filters-common (selectedChange)="onFilterChange($event)" [filtersData]="filtersData"></app-filters-common>

    <!-- ,     -->
    <app-filters-for-second-table><app-filters-for-second-table>

    <app-table-second [data]="data"></app-table-second>
  `,
})
export class SecondPageComponent {}


, : , . : ( , ) . , . , . app-root , - .

, , . :



. , . (, , ). . Angular : DI.



, , . Angular . , , . , ( ; , ), — . , (, ), . , (, , ), . , , .



. , Angular .





, , , . , . ?



:



, : . , Angular , ChangeDetectionStrategy.Default, (, - , ).



: . OnPush . - , .



, , : . , , .



:



, - . — RxJS.



Observable-:



import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class StorageService {
  // 
  // data = 'initial value';

  // 
  private dataSubject: BehaviorSubject<string> = new BehaviorSubject('initial value');
  data$: Observable<string> = this.dataSubject.asObservable();

  setData(newValue: string) {
    this.dataSubject.next(newValue);
  }
}


BehaviorSubject. Subject (. . Observable, Observer), , . data$. .



RxJS :



@Component({
  selector: 'my-app',
  template: `{{ data }}`,
})
export class AppComponent implements OnDestroy {
  data: any;

  subscription: Subscription = null;

  constructor(public storage: StorageService) {
    this.subscription = this.storage.data$.subscribe(data => this.data = data);
  }

  ngOnDestroy(): void {
    //    !
    if (this.subscription !== null) {
      this.subscription.unsubscribe();
      this.subscription = null;
    }
  }
}


, - , . , , (, ).



Angular , Promise Observable, — async. :



@Component({
  selector: 'my-app',
  template: `{{ data$ | async }}`, //   
})
export class AppComponent  {
  data$: Observable<any>;

  constructor(public storage: StorageService) {
    this.data$ = this.storage.data$;
  }
}


, , . ? :



  1. OnPush- , , . async .
  2. .


. , OnPush : async, OnPush- .







.



— : , . .



Observable-, :



export class BehaviorSubjectItem<T> {
  readonly subject: BehaviorSubject<T>;
  readonly value$: Observable<T>;

  //  :     `BehaviorSubject`,         .
  get value(): T {
    return this.subject.value;
  }

  set value(value: T) {
    this.subject.next(value);
  }

  constructor(initialValue: T) {
    this.subject = new BehaviorSubject(initialValue);
    this.value$ = this.subject.asObservable();
  }
}


:



@Injectable()
export class FiltersStore {
  private apiUrl = '/path/to/api';

  readonly filterData: BehaviorSubjectItem<{ value: string; text: string }[]> = new BehaviorSubjectItem([]);
  readonly selectedFilters: BehaviorSubjectItem<string[]> = new BehaviorSubjectItem([]);

  constructor(private http: HttpClient) {
    this.fetch(); //  -      
  }

  fetch() {
    this.http.get(this.apiUrl).subscribe(data => this.filterData.value = data);
  }

  setSelectedFilters(value: { value: string; text: string }[]) {
    this.selectedFilters.value = value;
  }
}


:



@Injectable()
export class TableStore {
  private apiUrl = '/path/to/api';

  tableData: BehaviorSubjectItem<any[]>;
  loading: BehaviorSubjectItem<boolean> = new BehaviorSubjectItem(false);

  constructor(private filterStore: FilterStore, private http: HttpClient) {
    this.fetch();
  }

  fetch() {
    this.tableData$ = this.filterStore.selectedFilters.value$.pipe(
      tap(() => this.loading.value = true),
      switchMap(selectedFilters => this.http.get(this.apiUrl, { params: selectedFilters })),
      tap(() => this.loading.value = false),
      share(),
    );
  }
}


(, ), combineLatest:



this.tableData$ = combineLatest(firstSource$, secondSource$).pipe(
  // start loading
  switchMap([firstData, secondData] => /* ... */)
  // end loading
);


:



@Component({
  selector: 'first-table-page',
  template: `
    <app-filters (selectedChange)="onFilterChange($event)" [filterData]="filterData$ | async"></app-filters>
    <app-table-first [data]="tableData$ | async" [loading]="loading$ | async"></app-table-first>
  `,
})
export class FirstTablePageComponent {
  filterData$: Observable<FilterData[]>;
  tableData$: Observable<TableItem[]>;
  loading$: Observable<boolean>;

  constructor(private filtersStore: FiltersStore, private tableStore: FirstTableStore) {
    this.filterData$ = filtersStore.filterData.value$;
    this.tableData$ = tableStore.tableData.value$;
    this.loading$ = tableStore.loading.value$;
  }

  onFilterChange(selectedFilters) {
    this.filtersStore.setSelectedFilters(selectedFilters)
    //   ,   
    this.filtersStore.selected.value = selectedFilters;
  }
}


:



  • - ;
  • - (, ) ;
  • ;
  • : , .




, store- : Single Responsibility, , . , :



  1. . , - , .
  2. . , 5–10 store-.
  3. . , Foo Bar, , Foo, , Foo Bar, . .
  4. , . , . . , - (, localStorage), . .


, . , : . , .



-, HTTP API , , . . — .



-, store- , :



export interface FilterItem<T> { 
  value: T; 
  text: string;
}

export interface FilterState<T> {
  data: FilterItem<T>[];
  selected: string[];
}

type ColorsFilterState = FilterState<string>;
type SizesFilterState = FilterState<int>;

export interface FiltersState {
  colors: ColorsFilterState;
  sizes: SizesFilterState;
}

const FILTERS_INITIAL_STATE: FiltersState = {
  colors: {
    data: [],
    selected: [],
  },
  sizes: {
    data: [],
    selected: [],
  },
};

@Injectable()
export class FiltersStore {
  filtersState: BehaviorSubjectItem<FiltersState> = new BehaviorSubjectItem(FILTERS_INITIAL_STATE);
}


-, :



@Injectable()
export class FiltersStore {
  filtersState: BehaviorSubjectItem<FiltersState> =  = new BehaviorSubjectItem({/* ... */});

  setColorsData(data: FilterItem<string>[]) {
    const oldState = this.filtersState.value;
    this.filtersState.value = {
      ...oldState,
      colors: {
        ...oldState.colors,
        data,
      },
    };
  }

  setColotsSelected(selected: string[]) {
    const oldState = this.filtersState.value;
    this.filtersState.value = {
      ...oldState,
      colors: {
        ...oldState.colors,
        selected,
      },
    };
  }
}


-, :



@Injectable()
export class FiltersStore {
  filtersState: BehaviorSubjectItem<FiltersState> =  = new BehaviorSubjectItem({/* ... */});
  setColorsData(data: FilterItem<string>[]) {/* ... */}
  setColotsSelected(selected: string[]) {/* ... */}

  colorsFilter$: Observable<ColorsFilterState> = this.filtersState.value$.pipe(
    map(filtersState => filtersState.colors), //  pluck('colors')
  );

  colorsFilterData$: Observable<FilterItem<string>[]> = this.colorsFilter$.pipe(
    map(colorsFilter => colorsFilter.data),
  );

  colorsFilterSelected$: Observable<string[]> = this.colorsFilter$.pipe(
    map(colorsFilter => colorsFilter.selected),
  );
}




. : . .





. . observable-, : , . , ( ). Flux, Facebook.



. :



  • - ;
  • ;
  • .


- . , , DI:



export interface AppState {/* ... */}

export const INITIAL_STATE: InjectionToken<AppState> = new InjectionToken('InitialState');

// -  AppModule
const appInitialState: AppState = {/* ... */};
@NgModule({
  providers: [
    Store,
    { provide: INITIAL_STATE, useValue: appInitialState },
  ]
})
export class AppModule {}


init , APP_INITIALIZER:



@Injectable()
export class Store<AppState> {
  state: BehaviorSubjectItem<AppState>;

  init(initialState: AppState) {
    this.state = new BehaviorSubjectItem(initialState);
  }
}

const appInitialState: AppState = {/* ... */};

export function initStore(store: Store<AppState>) {
  return () => store.init();
} 

@NgModule({
  providers: [
    Store,
    { 
      provide: APP_INITIALIZER,
      useFactory: initStore,
      deps: [Store], 
      multi: true,
    }
  ]
})
export class AppModule {}


, . — , .

. :



//       :
export interface ColorsFilterState {
  data: FilterItem<string>[];
  selected: string[];
}


: . - , , . , , :



interface ChangeColorsDataAction {
  type: 'changeData';
  payload: FilterItem<string>[];
}

interface ChangeColorsSelectedAction {
  type: 'changeSelected';
  payload: string[];
}

type ColorsActions = ChangeColorsDataAction | ChangeColorsSelectedAction;


, :



export const changeColorsDataState: (oldState: ColorsFilterState, data: FilterItem<string>[]) => FilterState = (oldState, data) => ({ ...oldState, data });

export const changeColorsSelectedState: (oldState: ColorsFilterState, selected: string[]) => FilterState = (oldState, selected) => ({ ...oldState, selected });


. , , :



export const changeColorsState: (oldState: ColorsFilterState, action: ColorsActions) => ColorsFilterState = (state, action) => {
  if (action.type === 'changeData') {
    return changeColorsDataState(oldState, action.payload);
  }
  if (action.type === 'changeSelected') {
    return changeColorsSelectedState(oldState, action.payload);
  }
  return oldState;
}


? , , ColorsFilterState:



@Injectable()
export class Store<ColorsFilterState> {
  state: BehaviorSubjectItem<ColorsFilterState>;
  init(
    state: ColorsFilterState, 
    changeStateFunction: (oldState: ColorsFilterState, action: ColorsActions) => ColorsFilterState, // <-      ,      
  ) {/* ... */}

  changeState(action: ColorsActions) {
    this.state.value = this.changeStateFunction(this.state.value, action);
  }
}
// 
this.store.changeState({ type: 'changeSelected', payload: ['red', 'orange'] });


, , :



  • ;
  • ;
  • , ;
  • ,

    .


. . , changeStateFunction, . . - , :



//     :
export interface FiltersState {
  colors: ColorsFilterState;
  sizes: SizesFilterState;
}
//        
export const changeColorsState = (state, action) => {/* ... */};
//        
export const changeSizeState = (state, action) => {/* ... */};

export const changeFunctionsMap = {
  colors: changeColorsState,
  sizes: changeSizeState,
};

export function combineChangeStateFunction(fnsMap) {
  return function (state, action) {
    const nextState = {};
    Object.entries(fnsMap).forEach(([key, changeFunction]) => {
      nextState[key] = changeFunction(state[key], action);
    });
    return nextState;
  }
}


- EventBus:



  • — ;
  • changeStateFunction

    ;
  • , ,

    - .


, , state manager'. - , , . , :





, .



NgRx



NgRx



NgRx Redux Angular. Redux , react-.



Redux NgRx . . — store. , . , «» — , ( ) . «» — , .



API - (, ), Middleware ( NgRx — Effect).



, Redux NgRx:





  1. view - ( , , XHR- . .).
  2. .
  3. , . , Middleware (Effect), API.
  4. , .
  5. View .
  6. API ( ):

    • ;
    • 4 5.


, . Redux .



NgRx



@ngrx/store



: . — Store. . Observable, — RxJS async-. , . , select. map pluck.



, . dispatch store .



export interface FilterItem<T> { 
  key: string; 
  value: T;
}
//  
export enum FiltersActionTypes {
  Change = '[Filter] Change',
  Reset = '[Filter] Reset',
}

export class ChangeFilterAction implements Action {
  readonly type = FiltersActionTypes.Change;
  constructor(public payload: { filterItem: FilterItem<string> }) {}
}

export class ResetFilterAction implements Action {
  readonly type = FiltersActionTypes.Reset;
  constructor() {}
}

export type FiltersActions = ChangeFilterAction | ResetFilterAction;

//      
export interface FiltersState {
  filterData: FilterItem<string>[];
  selected: FilterItem<string>;
}

export const FILTERS_INIT_STATE: FiltersState = {
  filterData: [
    { key: '-', value: '---' },
    { key: 'red', value: '' },
    { key: 'green', value: '' },
    { key: 'blue', value: '' },
  ],
  selected: { key: '-', value: '---' },
};

// ,     
export function filtersReducer(state: FiltersState = FILTERS_INIT_STATE, action: FiltersActions) {
  switch (action.type) {
    case FiltersActionTypes.Change:
      return {
        ...state,
        selected: action.payload.filterItem,
      };
    case FiltersActionTypes.Reset:
      return {
        ...state,
        selected: { key: '-', value: '---' },
      };
    default:
      return state;
  }
}

//     -,     select
//   ,  ,      
export const selectedFilterSelector = createSelector(state => state.filters.seleced);

//  
export interface AppState {
  filters: FiltersState;
}

export const reducers: ActionReducerMap<AppState> = {
  filters: filtersReducer,
}

@NgModule({
  imports: [
    StoreModule.forRoot(reducers),
  ],
  // ...
})
export class AppModule {}

// 
@Component({
  selector: 'app-root',
  template: `
    <app-filters [data]="filtersData$ | async" 
                 [selected]="selectedFilter$ | async" 
                 (change)="onChange($event)"
                 (reset)="onReset()">
  `,
})
export class AppComponent {
  filtersData$: Observable<FilterItem<string>[]>;
  selectedFilter$: Observable<FilterItem<string>>;

  constructor(private store: Store<AppState>) {
    this.filtersData$ = this.store.pipe(select('filters', 'data'));
    //  -
    this.selectedFilter$ = this.store.pipe(select(selectedFilterSelector));
  }

  onChange(filterItem: FilterItem) {
    this.store.dispatch(new ChangeFilterAction({ filterItem }));
  }

  onReset() {
    this.store.dispatch(new ResetFilterAction());
  }
}


. action, cli-.



@ngrx/effects



: -, . , , . , . @ngrx/store «» .



- : API, , . Redux Middleware, NgRx — . — @Effect.



, Effect, , . Observable<Action>. . Actions , - : , API . . - .



@Injectable()
export class AppEffects {
  //    
  @Effect({ dispatch: false })
  loginSuccess$ = this.actions$.pipe(
    ofType(LoginActionTypes.Success),
    tap(() => {
      this.logger.log('Login success')
    }),
  );

  //        API
  //   
  //    FiltersActionTypes.LoadStarted    API
  //        FiltersDataLoadSuccess     
  //  FiltersDataLoadFailure,   
  @Effect()
  loadFilterData$ = this.actions$.pipe(
    ofType(FiltersActionTypes.LoadStarted),
    switchMap(() => this.filtersBackendService.fetch()),
    map(filtersData => new FiltersDataLoadSuccess({ filtersData })),
    catchError(error => new FiltersDataLoadFailure({ error })),
  );

  // Actions —  Observable   @ngrx/store
  //        
  constructor(private actions$: Actions, private filtersBackendService: FiltersBackendService) {}
}


, -:



@NgModule({
  imports: [
    EffectsModule.forRoot([ AppEffects ]),
  ],
})
export class AppModule {}


@ngrx/store-devtools



Redux DevTools NgRx. . :



  • ;
  • ;
  • - .


@ngrx/store-devtools. — , — :





@ngrx/router-store



NgRx Angular. :



  • ;
  • ;
  • NgRx.


, . «» , DevTools . URL , , .



NgRx



NgRx — . , . , .



, , :



export interface FiltersState {/* ... */}
export const FILTERS_INIT_STATE: FiltersState = {/* ... */};
export function filtersReducer(state, action) {/* ... */}

export interface AppState {
  filters: FiltersState;
}

export const reducers: ActionReducerMap<AppState> = {
  filters: filtersReducer,
}


,

:



  1. .
  2. .
  3. .
  4. .


AppState :



export interface FirstTableState {/* ... */}
export const TABLE_INIT_STATE: FirstTableState = {/* ... */};
export function firstTableReducer(state, action) {/* ... */}
export const firstTableDataSelector = state => state.firstTable.data;
export const firstTableLoadingSelector = state => state.firstTable.loading;

export interface AppState {
  filters: FiltersState;
  firstTable: FirstTableState;
}

export const reducers: ActionReducerMap<AppState> = {
  filters: filtersReducer,
  firstTable: firstTableReducer,
}


, lazy loading . , ? , . , . , . , , , .



, NgRx. , . , . feature- . , :



@NgModule({
  imports: [
    StoreModule.forFeature('auth', authReducer),
    StoreModule.forFeature('user', userReducer),
    StoreModule.forFeature('config', configReducer),
    EffectsModule.forFeature([ AuthEffects, UserEffects, ConfigEffects ]),
  ],
})
export class LibModule {}


API . , API .



LibModule . API, .



NgRx





, . , NgRx .



NgRx . , . NgRx : , , .



. . , (, withLatestFrom RxJS). , .



. , . :



//   qux    foo  bar
// { foo: { qux: 1 }, bar: { baz: 2 } } => { foo: {}, bar: { qux: 1, baz: 2 } }
// 
export const fooReducer = (state, action) => {/* ... */}
export const quxSelector = state => state.foo.qux;

// 
export const barReducer = (state, action) => {/* ... */}
export const quxSelector = state => state.bar.qux;

export const reducers = {
  foo: fooReducer,
  bar: barReducer,
};

//    
@Component({})
export class AppComponent {
  constructor(private store: Store<AppState>) {
    this.qux$ = this.store.pipe(select(quxSelector));
  }
}


. NgRx :



@Injectable()
export class AppEffects {
  @Effect({ dispatch: false })
  log$ = this.actions$.pipe(tap(action => this.logger.log(action)));
  constructor(private actions$: Actions, private logger: Logger) {}
}


, . Actions , , :



@Injectable()
export class AppEffects {
  @Effect({ dispatch: false })
  log$ = this.actions$.pipe(tap(action => this.logger.log(action)));
  constructor(private actions$: Actions, private logger: Logger) {}
}


.





, NgRx .



. , .



. NgRx - . , ag-Grid. , , . «» ag-Grid . , - . ag-Grid , . , (, , ) , . , ag-Grid NgRx, ag-Grid . API, NgRx . : , , NgRx.



. , . Angular, . .





, , Angular.



, Angular 2 . .



NgRx . - ( , ) .



:



  1. , — .
  2. , — .
  3. , , — , :

    • , , HTTP API, ( backend-). backend- , store-.
    • : «» «». «» «» . «» , . «» @Input @Output. «» , «» .
    • , . , .
  4. , , , NgRx.
  5. NgRx, . NgRx. NgRx, — , .
  6. , , , , NgRx. .



All Articles