1
votes

I have a function which checks whether a grid has loaded or not and if not it triggers the loading. But currently this ends up firing several times for the same Loaded value so it will call the relevant action multiple times. I was under the impression that store selectors emit by default only distinct (changed) values?

My function

private gridLoaded(filters: FilteredListingInput): Observable<boolean> {
    return this.settings.states.Loaded.pipe(
        tap(loaded => {
            this.logService.debug(
                `Grid ID=<${
                    this.settings.id
                }> this.settings.states.Loaded state = ${loaded}`
            );

            // Now we get duplicate firings of this action.
            if (!loaded) {
                this.logService.debug(
                    `Grid Id=<${
                        this.settings.id
                    }> Dispatching action this.settings.stateActions.Read`
                );

                this.store.dispatch(
                    new this.settings.stateActions.Read(filters)
                );
            }
        }),
        filter(loaded => loaded),
        take(1)
    );
}

this.settings.states.Loaded is selector from NgRx store. The logging output I get looks like this:

Grid ID=<grid-reviewItem> this.settings.states.Loaded state = false {ignoreIntercept: true}
Grid Id=<grid-reviewItem> Dispatching action this.settings.stateActions.Read {ignoreIntercept: true}
Grid ID=<grid-reviewItem> this.settings.states.Loaded state = true {ignoreIntercept: true}
Grid ID=<grid-reviewItem> Calling FilterClientSide action. Loaded=true {ignoreIntercept: true}
Grid ID=<grid-reviewItem> this.settings.states.Loaded state = true {ignoreIntercept: true}
Grid ID=<grid-reviewItem> Calling FilterClientSide action. Loaded=true {ignoreIntercept: true}
Grid ID=<grid-reviewItem> this.settings.states.Loaded state = true {ignoreIntercept: true}
Grid ID=<grid-reviewItem> Calling FilterClientSide action. Loaded=true {ignoreIntercept: true}

How can I make sure that the relevant actions are triggered only once?

Edit - updates

Selector code:

export const getReviewItemsLoaded = createSelector(
    getReviewItemState,
    fromReviewItems.getReviewItemsLoaded
);

export const getReviewItemState = createSelector(
    fromFeature.getForecastState,
    (state: fromFeature.ForecastState) => {
        return state.reviewItems;
    }
);

export const getReviewItemsLoaded = (state: GridNgrxState<ReviewItemListDto>) =>
    state.loaded;

export interface GridNgrxState<TItemListDto> {
    allItems: TItemListDto[];
    filteredItems: TItemListDto[];
    totalCount: number;
    filters: FilteredListingInput;
    loaded: boolean;
    loading: boolean;
    selectedItems: TItemListDto[];
}

As you can see we are just getting the state.loaded property, it's a trivial selector.

Reducers that change the loading property:

export function loadItemsSuccessReducer(state: any, action: GridAction) {
    const data = action.payload;

    return {
        ...state,
        loading: false,
        loaded: true,
        totalCount: data.totalCount ? data.totalCount : data.items.length,
        allItems: data.items
    };
}

export function loadItemsReducer(state: any, action: GridAction) {
    return {
        ...state,
        loading: true,
        filters: action.payload
    };
}

export function loadItemsFailReducer(state: any, action: GridAction) {
    return {
        ...state,
        loading: false,
        loaded: false
    };
}

Actions

export class LoadReviewItemsAction implements Action {
    readonly type = LOAD_REVIEWITEMS;
    constructor(public payload?: FilteredListingInput) {}
}

export class LoadReviewItemsFailAction implements Action {
    readonly type = LOAD_REVIEWITEMS_FAIL;
    constructor(public payload: any) {}
}

export class LoadReviewItemsSuccessAction implements Action {
    readonly type = LOAD_REVIEWITEMS_SUCCESS;
    constructor(public payload: PagedResultDtoOfReviewItemListDto) {}

Effects

export class ReviewItemsEffects {
    constructor(
        private actions$: Actions,
        private reviewItemApi: ReviewItemApi
    ) {}

    @Effect()
    loadReviewItems$ = this.actions$
        .ofType(reviewItemActions.LOAD_REVIEWITEMS)
        .pipe(
            switchMap((action: reviewItemActions.LoadReviewItemsAction) => {
                return this.getDataFromApi(action.payload);
            })
        );

    /**
     * Retrieves and filters data from API
     */
    private getDataFromApi(filters: FilteredListingInput) {
        return this.reviewItemApi.getReviewItems(filters || {}).pipe(
            map(
                reviewItems =>
                    new reviewItemActions.LoadReviewItemsSuccessAction(
                        reviewItems
                    )
            ),
            catchError(error =>
                of(new reviewItemActions.LoadReviewItemsFailAction(error))
            )
        );
    }
}
1
There's distinct operator rxjs.dev/api/operators/distinct. Isn't that what you want? - martin
the createSelector is memoized, so it shouldn't execute if the input parameters remain the same. From the code shared it's hard to say what's going wrong - if you have a stackblitz I'm happy to take a look. - timdeschryver
This could happen if you override the state object, so its reference changes and selector emits every time. For more info please provide the whole chain - selector, reducer, action - Julius
@martin What I need actually is distinctUntilChanged however it didn't work. I've tried this without success: return this.settings.states.Loaded.pipe(distinctUntilChanged()).pipe( tap(loaded => { //... handle loading }), filter(loaded => loaded), take(1) ); Maybe I did it wrong? - Botond Béres
@timdeschryver Hard to post a working stackblitz as it's a quite complex component. But I've posted more relevant code. Also posted my current functional workaround for this. - Botond Béres

1 Answers

0
votes

I was able to work around the issue by refactoring the gridLoaded method into waitForGridLoaded and moving some of its logic outside of it. This works well but I couldn't solve the original issue of why the tap(loaded => ...) logic is triggered many times.

Now the relevant bits look like this (it doesn't feel like the nicest solution):

private initializeLoadingState() {
    const loadingStateSubscription = this.settings.states.Loading.subscribe(
        loading => {
            this.loading = loading;
        }
    );
    this.addSubscription(loadingStateSubscription);
}

private initializeLoadedState() {
    const loadedStateSubscription = this.settings.states.Loaded.subscribe(
        loaded => {
            this.loaded = loaded;
        }
    );
    this.addSubscription(loadedStateSubscription);
}

onLazyLoad(event: LazyLoadEvent) {
    // Do nothing yet if we are expecting to set parent filters
    // but we have not provided any parent filter yet
    if (
        this.settings.features.ParentFilters &&
        (!this.parentFiltersOnClient ||
            !this.parentFiltersOnClient.length) &&
        (!this.parentFiltersOnServer || !this.parentFiltersOnServer.length)
    ) {
        return;
    }

    this.loadAndFilterItems(event);
}

private loadAndFilterItems(event: LazyLoadEvent) {
    if (this.settings.features.ClientSideCaching) {
        if (this.loaded) {
            // Load only once and filter client side
            this.store.dispatch(
                new this.settings.stateActions.FilterClientSide(
                    this.buildFilters(event, GridParentFilterTypes.Client)
                )
            );
        } else if (!this.loading) {
            // Start loading in from server side
            this.store.dispatch(
                new this.settings.stateActions.Read(
                    this.buildFilters(event, GridParentFilterTypes.Server)
                )
            );

            // When we have finished loading, apply any client side filters
            const gridLoadedSubscription = this.waitForGridLoaded().subscribe(
                loaded => {
                    if (loaded) {
                        this.store.dispatch(
                            new this.settings.stateActions.FilterClientSide(
                                this.buildFilters(
                                    event,
                                    GridParentFilterTypes.Client
                                )
                            )
                        );
                    }
                }
            );
            this.addSubscription(gridLoadedSubscription);
        }
    } else {
        this.store.dispatch(
            new this.settings.stateActions.Read(
                this.buildFilters(event, GridParentFilterTypes.Server)
            )
        );
    }
}

private waitForGridLoaded(): Observable<boolean> {
    return this.settings.states.Loaded.pipe(
        filter(loaded => loaded),
        take(1)
    );
}