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:
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);
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?
Define the âview statesâ (loading, success, error, etc)
Define a single store where we will hold a âview stateâ for a particular action
Define logic that will tell that action is the âview stateâ action to filter it among others and set a âview stateâ for it
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 loadTodosSuccess = createAction(‘[Todo] Load Todos Success’, props<{ todos: Todo[] }>());
export const loadTodosFailure = createAction(‘[Todo] Load Todos Failure’, props<{ error: string }>());
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
console.log(loadTodos.type)
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
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>;
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.
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,
};
}
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
source: ‘ViewState’,
events: {
startLoading: props<{ actionType: string }>(),
reset: props<{ actionType: string }>(),
error: props<{ actionType: string; error?: unknown }>(),
},
});
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.
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
};
}
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.
startLoadingOn: Action; // An action that triggers the start of loading.
resetOn: Action[]; // A list of actions that reset state.
errorOn: Action[];
}
so we could write something like this:
startLoadingOn: TodosActions.loadTodos,
resetOn: [TodosActions.loadTodosSuccess],
errorOn: [TodosActions.loadTodosFailure]
},
Next, we need to create a service to register and store the configuration of actions.
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 });
});
});
}
}
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”
},
}
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.
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
});
}),
);
});
}
}
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:
// Export feature, and selectors we have created
export const {
viewStatesFeature,
selectActionStatus,
selectLoadingActions
} = createViewStateFeature<string>()
2.Provide ViewState as a feature slice
providers: [
provideStore({}),
provideState(viewStatesFeature),
provideState(todosFeature),
provideEffects(ViewStateEffects, TodosEffects),
]
};
3.Register actions in the TodosEffect
@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’ })))
)
)));
}
4.Select view state
export const selectTodosViewState = selectActionStatus(TodosActions.loadTodos);
export const selectActionsLoading = selectLoadingActions(
TodosActions.addTodo,
TodosActions.updateTodo,
TodosActions.deleteTodo
);
5.Dispatch loadTodos action
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());
}
}
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)
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);
}
}
And letâs use it in the template. Donât forget to import it into the component
<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>
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
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);
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.