NGXS global state's slices emit on every navigation

Issue

After tracking down unexpected requests being made I’ve found that my app’s global user state’s slices emit upon every navigation.

What are potential causes of an NGXS state slice

  1. emitting where its data hasn’t changed and
  2. actions have not been dispatched

Here is the chronological order of events in the app

  1. OnInit
  2. slice emits -> fetch with params <– EXPECTED
  3. user clicks to navigate to a new page
  4. UserState selectors emit -> fetch with params <– UNEXPECTED
  5. OnDestroy

Obviously we don’t want slices to emit for no changes in data.

#4 demonstrates a side-effect where an unnecessary request is triggered.

Here is the code

  @Selector()
  public static scope(state: DataModel) {
    console.log('scope triggered');
    return state.scope;
  }

  // Actions only trigger once as expected
  @Action(GetScopes)
  getAvailableScopes(ctx: StateContext<DataModel>) {
    return this.apiService.get('/scopes').pipe(
      tap((result) => {
        ctx.setState(
          patch<DataModel>({
            scope: result.scopes,
          })
        );
      })
    );
  }

The app is not complex but I cannot replicate unexpected slices emitting in StackBlitz Github issue

It should be noted that the state does not change!

ngxsOnChanges(change: NgxsSimpleChange) { ... only fires when the app inits and is never again triggered within in this flow despite the state’s selectors emitting on navigation.

Solution

The answer to the issue above is that state management has several weaknesses which your architecture will have to monitor.

One of them being that new subscriptions to selectors cause the selector to emit.

Navigating to a new route in the app can load another feature module. That feature module subscribes to the same Selectors causing the old subscriptions to emit despite the fact that they are about to be unsubscribed.

We solved this by setting up a resolver to ensure that all global states have been fetched and are available to every component. Then these globally shared states don’t have the timing issues and can be used without subscribing to them.

resolver

@Injectable({ providedIn: 'root' })
export class AppShellResolver implements Resolve<unknown> {
  constructor(private store: Store) {}
  resolve(route: ActivatedRouteSnapshot): Observable<any> {
    return this.store
      .dispatch([
        new GetAvailableScopes(),
        ... <-- other required states for UI
      ])
      ...
  }
}

usage

this.scopes = this.store.selectSnapshot(UserState.scopes);

Answered By – Ben Racicot

Answer Checked By – Terry (AngularFixing Volunteer)

Leave a Reply

Your email address will not be published.