Search code examples
angularngrxngrx-storengrx-store-4.0

Using NgRx to integrate object data into component, with local duplicate


In my Angular app, I have a page with a set of filters that can be toggled and then submitted at once.

I built this using a basic service, which has an object of filters (name of filter pointing to filter value). The data structure of this service is duplicated into a local version in the component (localFilters), which is updated as the user clicks checkboxes, etc. If the user clicks a button to submit the filters, the local filters are set to the global filters service, and if the user exits without submitting, it doesn't update the global service (and the localFilters are cleared on exit).

I've been running into issues getting components that use this data to remain in sync with the service and other components that use it, however, so I decided to try NgRx, understanding that this observable-based pattern is more typical for Angular and having used Redux in a number of React projects before.

I'm running into significant problems getting it set up, though, for two reasons:

  1. The pattern I was using before with the service involved setting a localFilters object, on component mount, based on the state of the global filters object. These localFilters would be used to determine the starting state of the filters on the page, and set globally on submission. With the observable pattern that NgRx uses, however, there is no filters object to copy -- there's only an observable, and I consequently can't figure out how to initialize the localFilters object. As a consequence, I don't know how to set the default status of the various filter components from this global object.

  2. More basically, I don't know how to show the filter values in the template (especially necessary if I can't copy its data to a local object). The basic getting started docs on NgRx show how to incorporate a numerical value into a template using the async pipe, but because my data is in object form, and I want to pass values of that object, this technique doesn't work. I've tried the following attempts based on the above link -- filters$ | async (shows [Object object]), filters$.someKey | async (shows nothing), and (filters$ | async).someKey (similarly shows nothing).

Basically, the big question is how I get access to a snapshot of the object being stored in NgRx state, both to initialize the local state of this filters component, and to render values from the object (or pass those values) in the template.

Or is there a better pattern I should be following? (Good examples have been hard to find, and would be much appreciated).

Below is a bunch of my relevant code.

Actions file:

import { Action } from '@ngrx/store';

export enum ActionTypes {
  SetFilter = 'SetFilter',
  SetFilters = 'SetFilters',
  ClearFilters = 'ClearFilters',
}

export class SetFilter implements Action {
  readonly type = ActionTypes.SetFilter;
  constructor(public name: string, public value: any) {}
}

export class SetFilters implements Action {
  readonly type = ActionTypes.SetFilters;
  constructor(public filters: object) {}
}

export class ClearFilters implements Action {
  readonly type = ActionTypes.ClearFilters;
}

export type ActionsUnion = SetFilter | SetFilters | ClearFilters;

Reducers file:

import * as FilterActions from './actions';

export interface State {
  filters: object
};

export const initialState: State = {
  filters: { wassup: 'true' } // testing initial state with some nonsense
};

export function reducer(state = initialState, action: FilterActions.ActionsUnion) {
  switch (action.type) {
    case FilterActions.ActionTypes.SetFilter: {
      return { ...state, [action.name]: action.value };
    }
    case FilterActions.ActionTypes.SetFilters: {
      return { ...state, ...action.filters };
    }
    case FilterActions.ActionTypes.ClearFilters: {
      return {};
    }
    default: return state;
  }
}

Abbreviated AppModule:

import { StoreModule } from '@ngrx/store';
import { reducer } from './ngrx/filters/reducer';

@NgModule({
  declarations: [...],
  imports: [
    ...,
    StoreModule.forRoot({ filters: reducer })
  ],
  ...
})

And an abbreviated version of the relevant component.ts file:

@Component({
  selector: 'app-base-filter',
  templateUrl: './base-filter.component.html',
  styleUrls: ['./base-filter.component.scss']
})
export class BaseFilterComponent implements OnInit {
  /** Object with selected indices for given filter keys. */
  selectedIndices: any = {};

  /** Duplicate all filters locally, to save on submit and clear on cancel */
  localFilters: any = {};

  filters$: Observable<object>;

  constructor(private store: Store<{ filters: object }>) {
    this.filters$ = store.pipe(select('filters'));

    this.initLocalFilters();
  }

  ngOnInit() {}

  // This worked with the old filtersService model
  // But is obviously broken here, because I haven't been able to init
  // localFilters correctly.
  initLocalFilters () {
    this.localFilters = {};

    // Fill pre-selections from filter service
    ['this', 'is a list of', 'names of filters with', 'an array of options']
      .forEach((arrayKey) => {
        // The selected indices are used in the template to pass to child 
        // components and determine selected content.
        this.selectedIndices[arrayKey] = (this.localFilters[arrayKey] || [])
          .map(t => this[arrayKey].indexOf(t));
      });
  }
});

I've tried, by the way, some of the below in the above component constructor:

// Doesn't throw an error, but doesn't enter the callback
this.store.select(data => { console.log(data) });

// Doesn't throw an error, but filter is undefined inside the loop
this.filters$ = store.pipe(select('filters'));
this.filters$.forEach(filter => { console.log(filter) });

Not sure whether looping through the keys/values of the filter is possible.


Solution

  • I found the answer after watching this slightly outdated but useful example video (for others who found the documentation pretty significantly lacking). Nothing too crazy. I just hadn't fully understood how RxJs integrates.

    My component code was all that had to change:

    import { Store, select } from '@ngrx/store';
    import { Observable } from 'rxjs';
    
    // These are local files. The @ format is just part
    // of some path aliasing I've set up.
    import { SetFilters } from '@store/filters/actions';
    import { AppState } from '@store/reducers'; // Reducer interface
    
    @Component({
      selector: 'app-base-filter',
      templateUrl: './base-filter.component.html',
      styleUrls: ['./base-filter.component.scss']
    })
    export class BaseFilterComponent implements OnInit {
      /** Object with selected indices for given. */
      selectedIndices: any = {};
    
      /** Duplicate all filters locally, to save on submit and clear on cancel */
      localFilters: any = {};
    
      /** Filters reducer */
      filters$: Observable<object>;
    
      constructor(private store: Store<AppState>) {
        this.filters$ = this.store.pipe(select('filters'));
        this.initLocalFilters();
      }
    
      ngOnInit() {}
    
      /**
       On component mount, clear any preexisting filters (if not dismounted),
       subscribe to filters in store, and initialize selectedIndices from filters.
      */
      initLocalFilters () {
        this.localFilters = {};
    
        this.filters$.subscribe(filters => {
          this.localFilters = { ...filters };
        });
    
        // Fill pre-selections from filter service
        ['this', 'is a list of', 'names of filters with', 'an array of options']
          .forEach((arrayKey) => {
            this.selectedIndices[arrayKey] = (this.localFilters[arrayKey] || [])
              .map(t => this[arrayKey].indexOf(t));
          });
      }
    
      ...
    
      submitFilters() {
        this.store.dispatch(new SetFilters(this.localFilters));
      }
    }
    

    Obviously, this doesn't directly solve question 2 (the templating object values question), but it does make it moot, because I'm able to easily duplicate store contents locally, and update them on submit.