Angular Router URL Parameters Using NgRx Router Store

danywalls

Dany Paredes

Posted on August 8, 2024

Angular Router URL Parameters Using NgRx Router Store

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

https://danywalls.com/when-to-use-concatmap-mergemap-switchmap-and-exhaustmap-operators-in-building-a-crud-with-ngrx

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Next, open app.routes.ts and register the PlaceEditComponent with the parameter /places/:id:

{
  path: 'places/:id',
  component: PlaceEditComponent,
},
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

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 }>(),
Enter fullscreen mode Exit fullscreen mode

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,
})),
Enter fullscreen mode Exit fullscreen mode

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 }
);
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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 }
);
Enter fullscreen mode Exit fullscreen mode

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 }
);
Enter fullscreen mode Exit fullscreen mode

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 }));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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']);
 }
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Next, open app.config and register routerReducer and provideRouterStore.

  providers: [
    ...,
    provideStore({
      router: routerReducer,
      home: homeReducer,
      places: placesReducer,
    }),
    ...
    provideRouterStore(),
  ],
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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(),
  },
});
Enter fullscreen mode Exit fullscreen mode

Update the cancel method in the PlacesComponent and dispatch the cancelPlace action.

 cancel() { 
    this.#store.dispatch(PlacesPageActions.cancelPlace());
  }
Enter fullscreen mode Exit fullscreen mode

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,
  },
);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
danywalls
Dany Paredes

Posted on August 8, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related