21

I am working on an Angular 5 project using NgRx 5. So far I've implemented a skeleton app and a feature module called "Search" which handles its own state, actions and reducers in an encapsulated fashion (by using the forFeature syntax).

This module has one root component (search-container) which renders an entire tree of child components - together they make up the search UI and functionality, which has a complex state model and a good number of actions and reducers.

There are strong requirements saying that:

  1. feature modules should be imported in isolation from each other, as per consumer app's requirements.

  2. multiple instances of the same feature should coexist inside the same parent (e.g. separate tabs with individual contexts)

  3. instances shouldn't have a shared internal state but they should be able to react to the same changes in the global state.

So my question is:

How can I have multiple <search-container></search-container> together and make sure that they function independently? For example, I want to dispatch a search action within one instance of the widget and NOT see the same search results in all of the widgets.

Any suggestions are much appreciated. Thanks!

Stefan Orzu
  • 413
  • 4
  • 13
  • found any solution to this ? – Parth Ghiya Aug 27 '18 at 03:21
  • 2
    @ParthGhiya Unfortunately not. What I did instead was assign IDs to each respective container at creation time. Therefore, a feature's state will look like a map of {id -> containerState}. Handling these adds a lot of extra complexity, such as providing the correct id to each container's set of child components, dispatching id-aware actions, decorating reducers to modify container state and using dynamically generated selectors because you can't pass container id as argument to an ngrx selector. I ended up writing an entire meta-framework around container management :( – Stefan Orzu Aug 28 '18 at 11:53

2 Answers2

18

I had a similar problem to yours and came up with the following way to solve it.

Reiterating your requirements just to make sure I understand them correctly:

  • You have one module "Search" with own components/state/reducer/actions etc.
  • You want to reuse that Module to have many search tabs, which all look and behave the same

Solution: Leverage meta data of actions

With actions, there is the concept of metadata. Basically, aside from the payload-Property, you also have a meta-property at the top level of your action object. This plays nicely with the concept of "have the same actions, but in different contexts". The metadata property would then be "id" (and more things, if you need them) to differentiate between the feature instances. You have one reducer inside your root state, define all actions once, and the metadata help the reducer/effects to know which "sub-state" is called.

The state looks like this:

export interface SearchStates {
  [searchStateId: string]: SearchState;
}

export interface SearchState {
  results: string;
}

An action looks like this:

export interface SearchMetadata {
  id: string;
}

export const search = (params: string, meta: SearchMetadata) => ({
  type: 'SEARCH',
  payload: params,
  meta
});

The reducer handles it like this:

export const searchReducer = (state: SearchStates = {}, action: any) => {
  switch (action.type) {
    case 'SEARCH':
      const id = action.meta.id;
      state = createStateIfDoesntExist(state, id);
      return {
        ...state,
        [id]: {
          ...state[id],
          results: action.payload
        }
      };
  }
  return state;
};

Your module provides the reducer and possible effects once for root, and for each feature (aka search) you provide a configuration with the metadata:

// provide this inside your root module
@NgModule({
  imports: [StoreModule.forFeature('searches', searchReducer)]
})
export class SearchModuleForRoot {}


// use forFeature to provide this to your search modules
@NgModule({
  // ...
  declarations: [SearchContainerComponent]
})
export class SearchModule {
  static forFeature(config: SearchMetadata): ModuleWithProviders {
    return {
      ngModule: SearchModule,
      providers: [{ provide: SEARCH_METADATA, useValue: config }]
    };
  }
}



@Component({
  // ...
})
export class SearchContainerComponent {

  constructor(@Inject(SEARCH_METADATA) private meta: SearchMetadata, private store: Store<any>) {}

  search(params: string) {
    this.store.dispatch(search(params, this.meta);
  }
}

If you want to hide the metadata complexity from your components, you can move that logic into a service and use that service in your components instead. There you can also define your selectors. Add the service to the providers inside forFeature.

@Injectable()
export class SearchService {
  private selectSearchState = (state: RootState) =>
    state.searches[this.meta.id] || initialState;
  private selectSearchResults = createSelector(
    this.selectSearchState,
    selectResults
  );

  constructor(
    @Inject(SEARCH_METADATA) private meta: SearchMetadata,
    private store: Store<RootState>
  ) {}

  getResults$() {
    return this.store.select(this.selectSearchResults);
  }

  search(params: string) {
    this.store.dispatch(search(params, this.meta));
  }
}

Usage inside your search tabs modules:

@NgModule({
  imports: [CommonModule, SearchModule.forFeature({ id: 'searchTab1' })],
  declarations: []
})
export class SearchTab1Module {}
// Now use <search-container></search-container> (once) where you need it

If you your search tabs all look exactly the same and have nothing custom, you could even change SearchModule to provide the searchContainer as a route:

export const routes: Route[] = [{path: "", component: SearchContainerComponent}];

@NgModule({
    imports: [
        RouterModule.forChild(routes)
    ]
    // rest stays the same
})
export class SearchModule {
 // ...
}


// and wire the tab to the root routes:

export const rootRoutes: Route[] = [
    // ...
    {path: "searchTab1", loadChildren: "./path/to/searchtab1.module#SearchTab1Module"}
]

Then, when you navigate to searchTab1, the SearchContainerComponent will be rendered.

...but I want to use multiple SearchContainerComponents inside a single module

You can apply the same pattern but on a component level:

Create metadata id randomly at startup of SearchService.
Provide SearchService inside SearchContainerComponent.
Don't forget to clean up the state when the service is destroyed.

@Injectable()
export class SearchService implements OnDestroy {
  private meta: SearchMetadata = {id: "search-" + Math.random()}
// ....
}


@Component({
  // ...
  providers: [SearchService]
})
export class SearchContainerComponent implements OnInit {
// ...
}

If you want the IDs to be deterministic, you have to hardcode them somewhere, then for example pass them as an input to SearchContainerComponent and then initialize the service with the metadata. This of course makes the code a little more complex.

Working example

Per module: https://stackblitz.com/edit/angular-rs3rt8

Per component: https://stackblitz.com/edit/angular-iepg5n

dummdidumm
  • 4,828
  • 15
  • 26
  • 1
    thank you for the detailed answer - this looks very similar to what I ended up implementing, with a few differences here and there, but the core idea is the same. Therefore I'm happy to mark this answer as a viable solution. I've done more work around encapsulating search metadata logic when dispatching actions and handling them inside reducers and effects, but it still feels like a lot of boilerplate. I wish that at some point the ngrx team would provide a more streamlined solution for this problem. – Stefan Orzu Sep 18 '18 at 09:00
  • Thanks, this helped me a lot with my implementation of virtual tabs in my project – Ash McConnell Apr 30 '19 at 10:03
0

I think you need this one @ngrx/component-store

xianshenglu
  • 4,943
  • 3
  • 17
  • 34