When we build apps with state, the entry point is key to initialize our state for our components, but sometimes, we have requirements to preserve application state within the URL to allow users to bookmark or share specific application states, with the goal of improves user experience and make easy the navigation.
Most of case, we combine the Angular Router and ActivatedRoute in our components to solve these cases and delegate this responsibility to the components or in others cases making a mix between components and the effect to try solve it.
I'm continuing my holidays in Menorca, so I took this morning to learn and practice how to handle state in the Angular Router and how the ngrx router can help improve my code and reduce the responsibility in my components.
Scenario
I want to create an edit page where users can modify the details of a selected place, share the URL, and return to the same state later. For example, http://localhost/places/2
, where 2
is the ID of the place being edited. Users should also be able to return to the home page after performing an action.
💡This article is part of my series on learning NgRx. If you want to follow along, please check it out.
https://www.danywalls.com/understanding-when-and-why-to-implement-ngrx-in-angular
https://www.danywalls.com/how-to-debug-ngrx-using-redux-devtools
https://www.danywalls.com/how-to-implement-actioncreationgroup-in-ngrx
https://www.danywalls.com/how-to-use-ngrx-selectors-in-angular
Clone the repo start-with-ngrx
, this project bring with ngrx and the application ready and switch to the branch crud-ngrx
https://github.com/danywalls/start-with-ngrx.git
git checkout crud-ngrx
It's time to coding!
The Edit Page
First open terminal and using the Angular CLI, generate a new component:
ng g c pages/place-edit
Next, open app.routes.ts
and register the PlaceEditComponent
with the parameter /places/:id
:
{
path: 'places/:id',
component: PlaceEditComponent,
},
Get The Place To Edit
My first solution is a combination of the service, effect, router and activated route. It will require make add logic in several places.
Add method in the places service.
Listen actions
set the success to update the state of the selected place.
read the selected place in edit-place.component.
First, add getById
method in the places.service.ts
, it get the place by using the id.
getById(id: string): Observable<Place> {
return this.http.get<Place>(`${environment.menorcaPlacesAPI}/${id}`);
}
Next, add new actions to handle the getById, open places.actions.ts
add the actions to edit, success and failure:
// PlacePageActions
'Edit Place': props<{ id: string }>(),
// PlacesApiActions
'Get Place Success': props<{ place: Place }>(),
'Get Place Failure': props<{ message: string }>(),
Update the reducer to handle these actions:
on(PlacesApiActions.getPlaceSuccess, (state, { place }) => ({
...state,
loading: false,
placeSelected: place,
})),
on(PlacesApiActions.getPlaceFailure, (state, { message }) => ({
...state,
loading: false,
message,
})),
Open place.effects.ts
, add a new effect to listen for the editPlace
action, call placesService.getById
, and then get the response to dispatch the getPlaceSuccess
action.
export const getPlaceEffect$ = createEffect(
(actions$ = inject(Actions), placesService = inject(PlacesService)) => {
return actions$.pipe(
ofType(PlacesPageActions.editPlace),
mergeMap(({ id }) =>
placesService.getById(id).pipe(
map((apiPlace) =>
PlacesApiActions.getPlaceSuccess({ place: apiPlace })
),
catchError((error) =>
of(PlacesApiActions.getPlaceFailure({ message: error }))
)
)
)
);
},
{ functional: true }
);
This solution seems promising. I need to dispatch the editPlace action and inject the router in place-card.component.ts
to navigate to the /places:id
route.
goEditPlace(id: string) {
this.store.dispatch(PlacesPageActions.editPlace({ id: this.place().id }));
this.router.navigate(['/places', id]);
}
It works! But there are some side effects. If you select another place and go back to the page, the selection might not be updated, and you may load the previous one. Also, with slow connections, you might get a "not found" error because it is still loading.
💡One solution, thanks to Jörgen de Groot, is to move the router to the effect. Open the places.effect.ts
file and inject the service and router. Listen for the editPlace action, get the data, then navigate and dispatch the action.
The final code looks like this:
export const getPlaceEffect$ = createEffect(
(
actions$ = inject(Actions),
placesService = inject(PlacesService),
router = inject(Router)
) => {
return actions$.pipe(
ofType(PlacesPageActions.editPlace),
mergeMap(({ id }) =>
placesService.getById(id).pipe(
tap(() => console.log('get by id')),
map((apiPlace) => {
router.navigate(['/places', apiPlace.id]);
return PlacesApiActions.getPlaceSuccess({ place: apiPlace });
}),
catchError((error) =>
of(PlacesApiActions.getPlaceFailure({ message: error }))
)
)
)
);
},
{ functional: true }
);
Now we fixed the issue of navigating only when the user click in the list of places, but when reloading the page that it's not working, because our state is not ready in the new route, but we have an option use the effect lifecycle hooks.
The effects lifecycle hooks allow us to trigger actions when the effects are register, so I wan trigger the action loadPlaces and have the state ready.
export const initPlacesState$ = createEffect(
(actions$ = inject(Actions)) => {
return actions$.pipe(
ofType(ROOT_EFFECTS_INIT),
map((action) => PlacesPageActions.loadPlaces())
);
},
{ functional: true }
);
Read more about Effect lifecycle and ROOT_EFFECTS_INIT
Okay, I have the state ready, but I'm still having an issue when getting the ID from the URL state.
A quick fix is to read the activatedRoute
in ngOnInit
. If the id
is present, dispatch the action editPlace
. This will redirect and set the selectedPlace
state.
So, inject activatedRoute
again in the PlaceEditComponent
and implement the logic in ngOnInit
.
The code looks like this:
export class PlaceEditComponent implements OnInit {
store = inject(Store);
place$ = this.store.select(PlacesSelectors.selectPlaceSelected);
activatedRoute = inject(ActivatedRoute);
ngOnInit(): void {
const id = this.activatedRoute.snapshot.params['id'];
if (id) {
this.store.dispatch(PlacesPageActions.editPlace({ id }));
}
}
}
It works! Finally, we add a cancel button to redirect to the places route and bind the click event to call a new method, cancel.
<button (click)="cancel()" class="button is-light" type="reset">Cancel</button>
Remember to inject the router
to call the navigate method to the places URL. The final code looks like this:
export class PlaceEditComponent implements OnInit {
store = inject(Store);
place$ = this.store.select(PlacesSelectors.selectPlaceSelected);
activatedRoute = inject(ActivatedRoute);
router = inject(Router);
ngOnInit(): void {
const id = this.activatedRoute.snapshot.params['id'];
if (id) {
this.store.dispatch(PlacesPageActions.editPlace({ id }));
}
}
cancel() {
router.navigate(['/places']);
}
}
Okay, it works with all features, but our component is handling many tasks, like dispatching actions and redirecting navigation. What will happen when we need more features? We can simplify everything by using NgRx Router, which will reduce the amount of code and responsibility in our components.
Why NgRx Router Store ?
The NgRx Router Store makes it easy to connect our state with router events and read data from the router using build'in selectors. Listening to router actions simplifies interaction with the data and effects, keeping our components free from extra dependencies like the router or activated route.
Router Actions
NgRx Router provide five router actions, these actions are trigger in order
ROUTER_REQUEST: when start a navigation.
ROUTER_NAVIGATION: before guards and revolver , it works during navigation.
ROUTER?NAVIGATED: When completed navigation.
ROUTER_CANCEL: when navigation is cancelled.
ROUTER_ERROR: when there is an error.
Read more about ROUTER_ACTIONS
Router Selectors
It helps read information from the router, such as query params, data, title, and more, using a list of built-in selectors provided by the function getRouterSelectors
.
export const { selectQueryParam, selectRouteParam} = getRouterSelectors()
Read more about Router Selectors
Because, we have an overview of NgRx Router, so let's start implementing it in the project.
Configure NgRx Router
First, we need to install NgRx Router. It provides selectors to read from the router and combine with other selectors to reduce boilerplate in our components.
In the terminal, install ngrx/router-store using the schematics:
ng add @ngrx/router-store
Next, open app.config
and register routerReducer
and provideRouterStore
.
providers: [
...,
provideStore({
router: routerReducer,
home: homeReducer,
places: placesReducer,
}),
...
provideRouterStore(),
],
We have the NgRx Router in our project, so now it's time to work with it!
Read more about install NgRx Router
Simplify using NgRx RouterSelectors
Instead of making an HTTP request, I will use my state because the ngrx init effect always updates my state when the effect is registered. This means I have the latest data. I can combine the selectPlaces
selector with selectRouterParams
to get the selectPlaceById
.
Open the places.selector.ts
file, create and export a new selector by combining selectPlaces
and selectRouteParams
.
The final code looks like this:
export const { selectRouteParams } = getRouterSelectors();
export const selectPlaceById = createSelector(
selectPlaces,
selectRouteParams,
(places, { id }) => places.find((place) => place.id === id),
);
export default {
placesSelector: selectPlaces,
selectPlaceSelected: selectPlaceSelected,
loadingSelector: selectLoading,
errorSelector: selectError,
selectPlaceById,
};
Perfect, now it's time to update and reduce all dependencies in the PlaceEditComponent
, and use the new selector PlacesSelectors.selectPlaceById
. The final code looks like this:
export class PlaceEditComponent {
store = inject(Store);
place$ = this.store.select(PlacesSelectors.selectPlaceById);
}
Okay, but what about the cancel action and redirect? We can dispatch a new action, cancel
, to handle this in the effect.
First, open places.action.ts
and add the action 'Cancel Place': emptyProps()
. the final code looks like this:
export const PlacesPageActions = createActionGroup({
source: 'Places',
events: {
'Load Places': emptyProps(),
'Add Place': props<{ place: Place }>(),
'Update Place': props<{ place: Place }>(),
'Delete Place': props<{ id: string }>(),
'Cancel Place': emptyProps(),
'Select Place': props<{ place: Place }>(),
'UnSelect Place': emptyProps(),
},
});
Update the cancel method in the PlacesComponent
and dispatch the cancelPlace
action.
cancel() {
this.#store.dispatch(PlacesPageActions.cancelPlace());
}
The final step is to open place.effect.ts
, add the returnHomeEffects
effect, inject the router, and listen for the cancelPlace
action. Use router.navigate
to redirect when the action is dispatched.
export const returnHomeEffect$ = createEffect(
(actions$ = inject(Actions), router = inject(Router)) => {
return actions$.pipe(
ofType(PlacesPageActions.cancelPlace),
tap(() => router.navigate(['/places'])),
);
},
{
dispatch: false,
functional: true,
},
);
Finally, the last step is to update the place-card to dispatch the selectPlace action and use a routerLink
.
<a (click)="goEditPlace()" [routerLink]="['/places', place().id]" class="button is-info">Edit</a>
Done! We did it! We removed the router and activated route dependencies, kept the URL parameter in sync, and combined it with router selectors.
Recap
I learned how to manage state using URL parameters with NgRx Router Store in Angular. I also integrated NgRx with Angular Router to handle state and navigation, keeping our components clean. This approach helps manage state better and combines with Router Selectors to easily read router data.
Source Code: https://github.com/danywalls/start-with-ngrx/tree/router-store
Resources: https://ngrx.io/guide/router-store
Top comments (1)
Hi Dany Paredes,
Top, very nice and helpful !
Thanks for sharing.