DEV Community

Yurii Khomitskyi
Yurii Khomitskyi

Posted on

A single state for Loading/Success/Error in NgRx

When handling HTTP requests in Angular applications, developers often need to manage multiple view states, such as loading, success, and error. Typically, these states are manually managed and stored in the NGRX Store, leading to boilerplate code if there are multiple features.

Here’s an example of how developers usually approach this situation:

interface TodosState {
  todos: Todo[];
  error: string | null;
  isLoading: boolean;
  isLoaded: boolean;
}


const initialState: TodosState = {
  todos: [],
  error: null,
  isLoading: false,
  isLoaded: false,
};

export const todosReducer = createReducer(
  initialState,
  on(TodosActions.loadTodos, state => ({
    ...state,
    isLoading: true,
    error: null
  })),
  on(TodosActions.loadTodosSuccess, (state, { todos }) => ({
    ...state,
    todos,
    isLoading: false,
    isLoaded: true
  })),
  on(TodosActions.loadTodosFailure, (state, { error }) => ({
    ...state,
    error,
    isLoading: false,
    isLoaded: false
  }))
);

// todos.selectors.ts
export const selectTodosState = (state: AppState) => state.todos;
export const selectTodos = createSelector(selectTodosState, state => state.todos);
export const selectTodosLoading = createSelector(selectTodosState, state => state.isLoading);
export const selectTodosError = createSelector(selectTodosState, state => state.error);
Enter fullscreen mode Exit fullscreen mode

In this approach, you have to create separate selectors and handle the loading, error, and success states manually. This scenario would likely repeat itself in some other reducer and we would end up repeating code

There are many ways of handling the “view states” but I found one that is convenient and makes use of NgRx actions and does not require a lot of boilerplate.

So what do we need to do?

  1. Define the “view states” (loading, success, error, etc)
  2. Define a single store where we will hold a “view state” for a particular action
  3. Define logic that will tell that action is the “view state” action to filter it among others and set a “view state” for it
  4. Select the “view state” from the store to display loading/success/error templates

If we want to load todos we would typically create 3 actions:

export const loadTodos = createAction('[Todo] Load Todos');
export const loadTodosSuccess = createAction('[Todo] Load Todos Success', props<{ todos: Todo[] }>());
export const loadTodosFailure = createAction('[Todo] Load Todos Failure', props<{ error: string }>());
Enter fullscreen mode Exit fullscreen mode

The “loadTodo” action is a trigger action so we could use it as a “unique id” or an action that defines the “view state” for “Todos”.

The “loadTodosSuccess” could be used to say that the “view state” for “loadTodo” is loaded.

The “loadTodosFailure” could be used to say that the “view state” for “loadTodo” is an error.

Since the NgRx action holds the static “type” property we could use it as a unique id

export const loadTodos = createAction('[Todo] Load Todos');
console.log(loadTodos.type)
Enter fullscreen mode Exit fullscreen mode

Define the “view states” (loading, success, error)

Let’s create the “ViewStatus” union type to list all possible view states. We'll use "ViewStatus" instead of "ViewState" to name the NgRx feature store state

export enum ViewStatusEnum {
  IDLE = 'idle',
  LOADING = 'loading',
  LOADED = 'loaded',
  ERROR = 'error',
}

export interface ViewIdle {
  readonly type: ViewStatusEnum.IDLE;
}

export interface ViewLoading {
  readonly type: ViewStatusEnum.LOADING;
}

export interface ViewLoaded {
  readonly type: ViewStatusEnum.LOADED;
}

export interface ViewError<E = unknown> {
  readonly type: ViewStatusEnum.ERROR;
  readonly error?: E;
}

export type ViewStatus<E = unknown> = ViewIdle | ViewLoading | ViewLoaded | ViewError<E>;
Enter fullscreen mode Exit fullscreen mode

The “ViewIdle” will be used to indicate that there is nothing in the store by the action. But you could use “ViewLoaded” if you prefer.

Let’s also create the “factories” functions.

export function loadingViewStatus(): ViewLoading {
  return { type: ViewStatusEnum.LOADING };
}

export function idleViewStatus(): ViewIdle {
  return {
   type: ViewStatusEnum.IDLE,
  };
}

export function loadedViewStatus(): ViewLoaded {
  return {
   type: ViewStatusEnum.LOADED,
  };
}

export function errorViewStatus<E>(error?: E): ViewError<E> {
  return {
   type: ViewStatusEnum.ERROR,
   error,
  };
}
Enter fullscreen mode Exit fullscreen mode

Define a single store where we will hold a “view state” for a particular action

Let’s define the ViewStateActions so that our store can operate with them

export const ViewStateActions = createActionGroup({
  source: 'ViewState',
  events: {
    startLoading: props<{ actionType: string }>(),
    reset: props<{ actionType: string }>(),
    error: props<{ actionType: string; error?: unknown }>(),
  },
});
Enter fullscreen mode Exit fullscreen mode

actionType is a string that represents the type of action (in our case it will be the “loadTodo.type” (the static property of an action).

The reset action will be used to remove the view state entity from the store.

Next define the state, reducer, and selectors:

We will make use of “@ngrx/entity” package to efficiently manage the collection of view states.

export interface ViewState<E> {
  actionType: string;
  viewStatus: ViewStatus<E>;
}

export function createViewStateFeature<E>() {
  const viewStatesFeatureKey = 'viewStates';

  const adapter: EntityAdapter<ViewState<E>> = createEntityAdapter<ViewState<E>>({
   selectId: (viewState: ViewState<E>) => viewState.actionType
  });

  const initialState = adapter.getInitialState({});

  const reducer = createReducer(
   initialState,
   on(ViewStateActions.startLoading, (state, { actionType }) => {
    return adapter.upsertOne({ actionType, viewStatus: loadingViewStatus() }, state);
   }),
   on(ViewStateActions.error, (state, { actionType, error }) => {
    return adapter.upsertOne({ actionType, viewStatus: errorViewStatus<E>(error as E) }, state);
   }),
   on(ViewStateActions.reset, (state, { actionType }) => {
    return adapter.removeOne(actionType, state);
   })
  );

  const viewStatesFeature = createFeature({
   name: viewStatesFeatureKey,
   reducer,
   extraSelectors: ({ selectViewStatesState, selectEntities }) => {
    function selectLoadingActions(...actions: Action[]): MemoizedSelector<object, boolean, DefaultProjectorFn<boolean>> {
     return createSelector(selectEntities, (actionStatuses: Dictionary<ViewState<E>>) => {
      return actions.some((action: Action): boolean => actionStatuses[action.type]?.viewStatus.type === ViewStatusEnum.LOADING);
     });
    }

    function selectActionStatus(action: Action): MemoizedSelector<object, ViewStatus<E>, DefaultProjectorFn<ViewStatus<E>>> {
     return createSelector(selectEntities, (actionsMap: Dictionary<ViewState<E>>): ViewStatus<E> => {
      return (actionsMap[action.type]?.viewStatus as ViewStatus<E>) ?? idleViewStatus();
     });
    }

    function selectViewState(action: Action): MemoizedSelector<object, ViewState<E>, DefaultProjectorFn<ViewState<E>>> {
     return createSelector(selectEntities, (actionsMap: Dictionary<ViewState<E>>): ViewState<E> => {
       return actionsMap[action.type] ?? { actionType: action.type, viewStatus: idleViewStatus() };
      }
     );
    }

    return {
     ...adapter.getSelectors(selectViewStatesState),
     selectLoadingActions,
     selectActionStatus,
     selectViewState
    };
   }
  });

  const { selectActionStatus, selectLoadingActions, selectViewState } = viewStatesFeature;

  return {
   initialState,
   viewStatesFeature,
   selectActionStatus,
   selectLoadingActions,
   selectViewState
  };
}
Enter fullscreen mode Exit fullscreen mode

To summarize what we have done here:

actionType is a string that represents the type of action (for example the “loadTodo.type” (the static property of an action)

viewStatus is an instance of ViewStatus, which represents the current state of the view.

We utilize the upsert method from the adapter to add the view state to the store if it’s not already there, or update it if it is.

We’ve also defined additional selectors, including:

  • selectActionsLoading: to select whether actions are in a loading state (useful for showing a loading overlay)
  • selectActionStatus: to select actions status. If there’s nothing in the state by this action, we return the idleViewStatus to indicate that it’s “loaded.”
  • selectViewState: to select the actual entity item from the store ({ actionType, viewStatus })

Define logic that will tell that action is the “view state” action to filter it among others and set a “view state” for it

First, we will create an interface to specify which actions are intended to be the “view state” actions.

export interface ViewStateActionsConfig {
  startLoadingOn: Action; // An action that triggers the start of loading.
  resetOn: Action[]; // A list of actions that reset state.
  errorOn: Action[];
}
Enter fullscreen mode Exit fullscreen mode

so we could write something like this:

{
  startLoadingOn: TodosActions.loadTodos,
  resetOn: [TodosActions.loadTodosSuccess],
  errorOn: [TodosActions.loadTodosFailure]
},
Enter fullscreen mode Exit fullscreen mode

Next, we need to create a service to register and store the configuration of actions.

export type ActionsMapConfig = { viewState: 'startLoading'  } | { viewState: 'reset', actionType: string } | { viewState: 'error', actionType: string };

export interface ViewStateActionsConfig {
  startLoadingOn: Action;
  resetOn: Action[];
  errorOn: Action[];
}


@Injectable({
  providedIn: 'root',
})
export class ViewStateActionsService {
  private actionsMap = new Map<string, ActionsMapConfig>();

  public isStartLoadingAction(action: Action): boolean {
    return this.actionsMap.get(action.type)?.viewState === 'startLoading';
  }

  public isResetLoadingAction(action: Action): boolean {
    return this.actionsMap.get(action.type)?.viewState === 'reset';
  }

  public isErrorAction(action: Action): boolean {
    return this.actionsMap.get(action.type)?.viewState === 'error';
  }

  public getActionType(action: Action): string | null {
    const actionConfig = this.actionsMap.get(action.type);
    if (!actionConfig) {
      return null;
    }

    if (actionConfig.viewState === 'startLoading') {
      return null;
    }

    return actionConfig.actionType
  }

  public add(actions: ViewStateActionsConfig[]): void {
    actions.forEach((action: ViewStateActionsConfig) => {
      this.actionsMap.set(action.startLoadingOn.type, { viewState: 'startLoading' });

      action.resetOn.forEach((resetLoading: Action) => {
        this.actionsMap.set(resetLoading.type, { viewState: 'reset', actionType: action.startLoadingOn.type });
      });

      action.errorOn.forEach((errorAction: Action) => {
        this.actionsMap.set(errorAction.type, { viewState: 'error', actionType: action.startLoadingOn.type });
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The idea of this service is to store which actions are meant to trigger loading, reset, or an error state.

There is an example of how our map will look for the following configuration:

{
  startLoadingOn: TodosActions.loadTodos,
  resetOn: [TodosActions.loadTodosSuccess],
  erroOn: [TodosActions.loadTodosFailure]
},

private actionsMap = new Map<string, ActionsMapConfig>();

{
    "[Todo] Load Todos": {
        "viewState": "startLoading"
    },
    "[Todo] Load Todos Success": {
        "viewState": "reset",
        "actionType": "[Todo] Load Todos"
    },
    "[Todo] Load Todos Failure": {
        "viewState": "error",
        "actionType": "[Todo] Load Todos"
    },
}
Enter fullscreen mode Exit fullscreen mode

For "success" and "failure," we store the unique id actionType. This actionType is used to set the "view state" in the state, so we will have to update the "view state" using this id.

We now have a service that helps us determine “view state” actions. We can create an effect to filter and dispatch ViewStateActions to store the “view state” in the state.

@Injectable()
export class ViewStateEffects {
  public startLoading$ = this.startLoading();
  public reset$ = this.reset();
  public error$ = this.error();

  constructor(
    private actions$: Actions,
    private viewStateActionsService: ViewStateActionsService,
  ) {}

  private startLoading() {
    return createEffect(() => {
      return this.actions$.pipe(
        filter((action: Action) => {
          return this.viewStateActionsService.isStartLoadingAction(action);
        }),
        map((action: Action) => {
          return ViewStateActions.startLoading({ actionType: action.type });
        }),
      );
    });
  }

  private reset() {
    return createEffect(() => {
      return this.actions$.pipe(
        filter((action: Action) => {
          return this.viewStateActionsService.isResetLoadingAction(action);
        }),
        map((action: Action ) => {
          return ViewStateActions.reset({ actionType: this.viewStateActionsService.getActionType(action) ?? '' });
        }),
      );
    });
  }

  private error() {
    return createEffect(() => {
      return this.actions$.pipe(
        filter((action: Action) => {
          return this.viewStateActionsService.isErrorAction(action);
        }),
        map((action: Action) => {
          return ViewStateActions.error({
            actionType: this.viewStateActionsService.getActionType(action) ?? '',
            error: (action as Action & ViewStateErrorProps)?.viewStateError ?? undefined
          });
        }),
      );
    });
  }
}

Enter fullscreen mode Exit fullscreen mode

In this effect, we listen to “view-state” actions and dispatch corresponding actions, also for reset and error we get the actionType to update the view state in the state

Bringing all together:

1.Create view state feature:

// view-state.feature.ts

// Export feature, and selectors we have created
export const { 
viewStatesFeature, 
selectActionStatus, 
selectLoadingActions 
} = createViewStateFeature<string>()
Enter fullscreen mode Exit fullscreen mode

2.Provide ViewState as a feature slice

export const appConfig: ApplicationConfig = {
  providers: [
   provideStore({}),
   provideState(viewStatesFeature),
   provideState(todosFeature),
   provideEffects(ViewStateEffects, TodosEffects),

  ]
};
Enter fullscreen mode Exit fullscreen mode

3.Register actions in the TodosEffect

// todos.effects.ts

@Injectable()
export class TodosEffects {
  public getTodos$ = this.getTodos();

  constructor(private actions$: Actions, private todosService: TodosService, private viewStateActionsService: ViewStateActionsService) {
   this.viewStateActionsService.add([
    {
     startLoadingOn: TodosActions.loadTodos,
     resetOn: [TodosActions.loadTodosSuccess],
     errorOn: [TodosActions.loadTodosFailure]
    },
    // add, update, delete will look similar
  }

  private getTodos() {
   return createEffect(() => this.actions$.pipe(
    ofType(TodosActions.loadTodos),
    switchMap(() => this.todosService.getTodos().pipe(
      map(todos => TodosActions.loadTodosSuccess({ todos })),
      catchError(() => of(TodosActions.loadTodosFailure({ viewStateError: 'Could not load todos' })))
     )
    )));
  }
Enter fullscreen mode Exit fullscreen mode

4.Select view state

// todos.selectors.ts

export const selectTodosViewState = selectActionStatus(TodosActions.loadTodos);
export const selectActionsLoading = selectLoadingActions(
TodosActions.addTodo, 
TodosActions.updateTodo, 
TodosActions.deleteTodo
);
Enter fullscreen mode Exit fullscreen mode

5.Dispatch loadTodos action

@Component({
  selector: 'app-todos',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './todos.component.html',
  styleUrl: './todos.component.css'
})
export class TodosComponent {
  public todos$ = this.store.select(selectTodos);
  public viewStatus$ = this.store.select(selectTodosViewState);
  public isOverlayLoading$ = this.store.select(selectActionsLoading);


  constructor(private readonly store: Store) {
   this.store.dispatch(TodosActions.loadTodos());
  }

}
Enter fullscreen mode Exit fullscreen mode

Display loading/success/error templates based on ViewState

As we defined the "view state" at the beginning and have a selector that selects the viewStatus, we can now create a structural directive to conditionally render templates.

Here is an example of how a directive could look (This code is just a concept)

@Directive({
  selector: '[ngxViewState]',
  standalone: true,
})
export class ViewStateDirective {

  @Input({ required: true, alias: 'ngxViewState' })
  public set viewState(value: ViewStatus | null) {
    // If we use the async pipe the first value will be null
    if (value == null) {
      this.viewContainerRef.clear();
      this.createSpinner();
      return;
    }

    this.onViewStateChange(value);
  }


  private viewStatusHandlers = {
    [ViewStatusEnum.IDLE]: () => {
      this.createContent();
    },
    [ViewStatusEnum.LOADING]: () => {
      this.createSpinner();
    },
    [ViewStatusEnum.LOADED]: () => {
      this.createContent();
    },
    [ViewStatusEnum.ERROR]: (viewStatus) => {
      this.createErrorState(viewStatus.error);
    },
  };

  constructor(
    private viewContainerRef: ViewContainerRef,
    private templateRef: TemplateRef<ViewStateContext<T>>,
    private cdRef: ChangeDetectorRef,
    @Inject(ERROR_STATE_COMPONENT)
    private errorStateComponent: Type<ViewStateErrorComponent<unknown>>,
    @Inject(LOADING_STATE_COMPONENT)
    private loadingStateComponent: Type<unknown>,
  ) {}

  private onViewStateChange(viewStatus: ViewStatus): void {
    this.viewContainerRef.clear();

    this.viewStatusHandlers[viewStatus.type](viewStatus);
    this.cdRef.detectChanges();
  }

  private createContent(): void {
    this.viewContainerRef.createEmbeddedView(this.templateRef, this.viewContext);
  }

  private createSpinner(): void {
      this.viewContainerRef.createComponent(this.loadingStateComponent);
  }

  private createErrorState(error?: unknown): void {
      const component = this.viewContainerRef.createComponent(this.errorStateComponent);
      component.setInput('viewStateError', error);
  }
}
Enter fullscreen mode Exit fullscreen mode

And let’s use it in the template. Don’t forget to import it into the component

<!-- todos.component.html -->
<ng-container *ngxViewState="viewStatus$ | async">
    <table *ngIf="todos$ | async as todos" mat-table [dataSource]="todos">
        // Render todos
    </table>
  <div class="loading-shade" *ngIf="isOverlayLoading$ | async">
    <app-loading></app-loading>
   </div>
</ng-container>
Enter fullscreen mode Exit fullscreen mode

That is basically it.

So now all you have to do in other features that need loading/success/error view states is define view state actions in a effect and create selectors

// articles.effects.ts
this.viewStateActionsService.add([
    {
        startLoadingOn: ArticlesActions.loadArticles,
        resetOn: [ArticlesActions.loadArticlesSuccess],
        errorOn: [ArticlesActions.loadArticlesFailure]
    },
]);

// articles.selectors.ts
export const selectArticlesViewState = selectActionStatus(ArticlesActions.loadArticles);

// books.effects.ts
this.viewStateActionsService.add([
    {
        startLoadingOn: BooksActions.loadBooks,
        resetOn: [BooksActions.loadBooksSuccess],
        errorOn: [BooksActions.loadBooksFailure]
    },
]);

// books.selectors.ts
export const selectBooksViewState = selectActionStatus(BooksActions.loadBooks);
Enter fullscreen mode Exit fullscreen mode

I believe it's a great time to introduce my library that can handle everything we've covered here.

The ngx-view-state library simplifies this process by providing a centralized way to handle view states such as loading, error, and loaded. And it comes with some other useful utils.

For a live demonstration, visit stackblitz.

Top comments (0)