Using Data Persistence operators
Managing state is a hard problem. We need to coordinate multiple backends, web workers, and UI components, all of which update the state concurrently.
What should we store in memory and what in the URL? What about the local UI state? How do we synchronize the persistent state, the URL, and the state on the server? All these questions have to be answered when designing the state management of our applications. Nx provides a set of helper functions that enables the developer to manage state in Angular with an intentional synchronization strategy and handle error state. Check out the Managing State in Angular Applications using NgRx for more detailed example of the state problem Nx is solving.
Optimistic Updates
For a better user experience, the optimisticUpdate
operator updates the state on the client application first, before updating the data on the server-side. While it addresses fetching data in order, removing the race conditions and handling error, it is optimistic about not failing to update the server. In case of a failure, when using optimisticUpdate
, the local state on the client is already updated. The developer must provide an undo action to restore the previous state to keep it consistent with the server state. The error handling must be done in the callback, or by means of the undo action.
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { optimisticUpdate } from '@nx/angular';
()
class TodoEffects {
updateTodo$ = createEffect(() =>
this.actions$.pipe(
ofType('UPDATE_TODO'),
optimisticUpdate({
// provides an action
run: (action: UpdateTodo) => {
return this.backend.updateTodo(action.todo.id, action.todo).pipe(
mapTo({
type: 'UPDATE_TODO_SUCCESS',
})
);
},
undoAction: (action: UpdateTodo, error: any) => {
// dispatch an undo action to undo the changes in the client state
return {
type: 'UNDO_TODO_UPDATE',
todo: action.todo,
};
},
})
)
);
constructor(private actions$: Actions, private backend: Backend) {}
}
Pessimistic Updates
To achieve a more reliable data synchronization, the pessimisticUpdate
operator updates the server data first. When the change is reflected in the server state, changes the client state by dispatching an action. pessimisticUpdate
method enforces the order of the fetches and error handling.
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { pessimisticUpdate } from '@nx/angular';
()
class TodoEffects {
updateTodo$ = createEffect(() =>
this.actions$.pipe(
ofType('UPDATE_TODO'),
pessimisticUpdate({
// provides an action
run: (action: UpdateTodo) => {
// update the backend first, and then dispatch an action that will
// update the client side
return this.backend.updateTodo(action.todo.id, action.todo).pipe(
map((updated) => ({
type: 'UPDATE_TODO_SUCCESS',
todo: updated,
}))
);
},
onError: (action: UpdateTodo, error: any) => {
// we don't need to undo the changes on the client side.
// we can dispatch an error, or simply log the error here and return `null`
return null;
},
})
)
);
constructor(private actions$: Actions, private backend: Backend) {}
}
Data Fetching
The fetch
operator provides consistency when fetching data. If there are multiple requests scheduled for the same action, it will only run the last one.
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { fetch } from '@nx/angular';
()
class TodoEffects {
loadTodos$ = createEffect(() =>
this.actions$.pipe(
ofType('GET_TODOS'),
fetch({
// provides an action
run: (a: GetTodos) => {
return this.backend.getAll().pipe(
map((response) => ({
type: 'TODOS',
todos: response.todos,
}))
);
},
onError: (action: GetTodos, error: any) => {
// dispatch an undo action to undo the changes in the client state
return null;
},
})
)
);
constructor(private actions$: Actions, private backend: Backend) {}
}
This is correct, but we can improve the performance by supplying an id of the data by using an accessor function and adding concurrency to the fetch action for different ToDo's.
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { fetch } from '@nx/angular';
()
class TodoEffects {
loadTodo$ = createEffect(() =>
this.actions$.pipe(
ofType('GET_TODO'),
fetch({
id: (todo: GetTodo) => {
return todo.id;
},
// provides an action
run: (todo: GetTodo) => {
return this.backend.getTodo(todo.id).pipe(
map((response) => ({
type: 'LOAD_TODO_SUCCESS',
todo: response.todo,
}))
);
},
onError: (action: GetTodo, error: any) => {
// dispatch an undo action to undo the changes in the client state
return null;
},
})
)
);
constructor(private actions$: Actions, private backend: Backend) {}
}
With this setup, the requests for Todo will run concurrently with the requests for Todo 2.
Data Fetching On Router Navigation
Since the user can always interact with the URL directly, we should treat the router as the source of truth and the initiator of actions. In other words, the router should invoke the reducer, not the other way around.
When our state depends on navigation, we can not assume the route change happened when a new url is triggered but when we actually know the user was able to navigate to the url. The navigation
operator checks if an activated router state contains the passed in component type, and, if it does, runs the run
callback. It provides the activated snapshot associated with the component and the current state. And it only runs the last request.
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { navigation } from '@nx/angular';
()
class TodoEffects {
loadTodo$ = createEffect(() =>
this.actions$.pipe(
// listens for the routerNavigation action from @ngrx/router-store
navigation(TodoComponent, {
run: (activatedRouteSnapshot: ActivatedRouteSnapshot) => {
return this.backend
.fetchTodo(activatedRouteSnapshot.params['id'])
.pipe(
map((todo) => ({
type: 'LOAD_TODO_SUCCESS',
todo: todo,
}))
);
},
onError: (
activatedRouteSnapshot: ActivatedRouteSnapshot,
error: any
) => {
// we can log and error here and return null
// we can also navigate back
return null;
},
})
)
);
constructor(private actions$: Actions, private backend: Backend) {}
}
The StoreRouterConnectingModule must be configured with an appropriate serializer. The FullRouterStateSerializer
provides the full router state instead of the MinimalRouterStateSerializer
that is used without configuration.
import { NgModule } from '@angular/core';
import {
StoreRouterConnectingModule,
FullRouterStateSerializer,
} from '@ngrx/router-store';
({
imports: [
StoreRouterConnectingModule.forRoot({
serializer: FullRouterStateSerializer,
}),
],
})
export class TodosModule {}