How I’ve Set Up NgRx Router Store with Standalone Components in Angular 16

Router Store to help simplify router usage

Kamil Konopka
Bits and Pieces

--

Photo by Markus Spiske on Unsplash

Have you ever been in a position, where you had to use router within Angular with multiple levels of nesting, and you had to access either children or parent routes to get parameters/query parameters from there and use them all together?

I guess most of us had this challenge before.

You can either carry on with accessing all the levels you need and produce code which will be difficult to understand and maintain after a while or you can start thinking that there’s actually a better way to handle this scenario.

One of the possible options is @ngrx/router-store library which, with little configuration from our end will help us manage all parameters / query parameters/ urlor even datataken from the resolver output.

Here you can find some more information about handling resolvers in Angular 16:

Don’t want to go into the initial setup of NgRx (I’ve done it before :-)), but if you’re not there yet, please have a look at one of my previous articles related to this topic:

Or even some more about Feature Creators (an alternative way to set up NgRx state management within your application):

Now that we’re on the same page and your NgRx initial implementation is already up and running, we can finally focus on Router Store. Library installation was never easier, we can either use NPM or YARN, depending on what’s your project preference.

For NPM, use the command below within the terminal in your project folder:

npm install @ngrx/router-store --save

In the case of YARN, use the command below within the terminal in your project folder:

yarn add @ngrx/router-store

Now, as we are using the Standalone Components approach, we need to go to the main.ts file and add provideRouterStore() provider from the package we’ve just installed. Here’s what it should look like:

import { enableProdMode, isDevMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { environment } from './environments/environment';
import { AppComponent } from './app/app.component';
import { rootRoutes } from './app/root-routes';
import { provideRouter } from '@angular/router';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { messagesReducers } from './app/store/messages';
import { provideRouterStore } from '@ngrx/router-store'; // <-- our Router Store provider import

if (environment.production) {
enableProdMode();
}

bootstrapApplication(AppComponent, {
providers: [
provideRouter(rootRoutes),
provideStore({ messages: messagesReducers }),
provideRouterStore(), // <-- Router Store provider declaration
provideStoreDevtools({
maxAge: 25, // Retains last 25 states
logOnly: !isDevMode(), // Restrict extension to log-only mode
autoPause: true, // Pauses recording actions and state changes when the extension window is not open
trace: false, // If set to true, will include stack trace for every dispatched action, so you can see it in trace tab jumping directly to that part of code
traceLimit: 75, // maximum stack trace frames to be stored (in case trace option was provided as true)
}),
],
}).catch(err => console.error(err));

If you now open the browser and take a look into Redux Devtools you’ll notice a bunch of new actions being dispatched already, although we neither declared any action nor provided any reducer, yet. These are being added by default, by the library itself. This is what you should see:

Fig 1. I’ve filtered out @ngrx store-related actions only :-).

As we haven’t declared any reducer yet, our state doesn’t contain a router, we have to pass its declaration to the provideStore() provider, just like in the example below. Again nothing to create manually, we are just passing routerReducer function to the state attribute (the name depends on our choice :-)).

import { enableProdMode, isDevMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { environment } from './environments/environment';
import { AppComponent } from './app/app.component';
import { rootRoutes } from './app/root-routes';
import { provideRouter } from '@angular/router';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { messagesReducers } from './app/store/messages';
import { provideRouterStore, routerReducer } from '@ngrx/router-store'; // <-- our Router Reducer im

if (environment.production) {
enableProdMode();
}

bootstrapApplication(AppComponent, {
providers: [
provideRouter(rootRoutes),
provideStore({ messages: messagesReducers, router: routerReducer }), // <-- Router Reducer declaration
provideRouterStore(),
provideStoreDevtools({
maxAge: 25, // Retains last 25 states
logOnly: !isDevMode(), // Restrict extension to log-only mode
autoPause: true, // Pauses recording actions and state changes when the extension window is not open
trace: false, // If set to true, will include stack trace for every dispatched action, so you can see it in trace tab jumping directly to that part of code
traceLimit: 75, // maximum stack trace frames to be stored (in case trace option was provided as true)
}),
],
}).catch(err => console.error(err));

Now, when we will have another look into the state object, our router state will be present like below:

Fig 2. NgRx state now contains our router state

The initial setup is done, but we haven’t finished yet. We need to configure our @ngrx/router-store to perform params and query params flattening, so our state will contain these as simple Record<string, string>.

In order to be able to make it happen we need to create a class which will implement RouterStateSerializer interface from @ngrx/router-store library. This is where we will do the flattening.

We need to create a separate router-state-serializer.tsfile, which could be located within our router-store folder with the code below:

import { ActivatedRouteSnapshot, Data, Params, RouterStateSnapshot } from '@angular/router';
import { RouterStateSerializer } from '@ngrx/router-store';
import { RouterState } from '@shared/models';

export class CustomRouterStateSerializer implements RouterStateSerializer<RouterState> {
serialize = (state: RouterStateSnapshot): RouterState => ({
url: state.url,
params: mergeRouteParams(state.root, ({ params }) => params),
queryParams: mergeRouteParams(state.root, ({ queryParams }) => queryParams),
data: mergeRouteData(state.root),
});
}

const mergeRouteParams = (
route: ActivatedRouteSnapshot,
getter: (activatedRoute: ActivatedRouteSnapshot) => Params
): Params =>
!route
? {}
: {
...getter(route),
...mergeRouteParams(
(route.children.find(({ outlet }) => outlet === 'primary') || route.firstChild) as ActivatedRouteSnapshot,
getter
),
};

const mergeRouteData = (route: ActivatedRouteSnapshot): Data =>
!route
? {}
: {
...route.data,
...mergeRouteData(
(route.children.find(({ outlet }) => outlet === 'primary') || route.firstChild) as ActivatedRouteSnapshot
),
};

Let’s analyze the code above. We’ve just created our CustomRouterStateSerializer class, which extends default RouterStateSerializer from @ngrx/router-store library and implements serialize method returning my expected RouterStore data structure.

As you’ve probably noticed from the import, I’ve defined RouterStore the interface on my own. It might differ from your needs, so feel free to alter it if that’s the case. Nevertheless, my interface is being declared as below:

import { Data, Params } from '@angular/router';

export interface RouterState {
url: string;
queryParams: Params;
params: Params;
data: Data;
}

Ok, let’s carry on with the serializer code analysis. We’ve declared two functions expressions: mergeRouteParams and mergeRouteData. The purpose is quite obvious, just worth mentioning, we will use mergeRouteParams for both: parameters and queryParameters.

Now, we need to replace the default serializer with our custom one. In order to achieve it, we need to alter main.ts file a bit:

import { enableProdMode, isDevMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { environment } from './environments/environment';
import { AppComponent } from './app/app.component';
import { rootRoutes } from './app/root-routes';
import { provideRouter } from '@angular/router';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { messagesReducers } from './app/store/messages';
import { provideRouterStore, routerReducer } from '@ngrx/router-store';
import { CustomRouterStateSerializer } from '@store/router'; // <-- our import

if (environment.production) {
enableProdMode();
}

bootstrapApplication(AppComponent, {
providers: [
provideRouter(rootRoutes),
provideStore({ messages: messagesReducers, router: routerReducer }),
provideRouterStore({ serializer: CustomRouterStateSerializer }), // <-- we pass it here :-)
provideStoreDevtools({
maxAge: 25, // Retains last 25 states
logOnly: !isDevMode(), // Restrict extension to log-only mode
autoPause: true, // Pauses recording actions and state changes when the extension window is not open
trace: false, // If set to true, will include stack trace for every dispatched action, so you can see it in trace tab jumping directly to that part of code
traceLimit: 75, // maximum stack trace frames to be stored (in case trace option was provided as true)
}),
],
}).catch(err => console.error(err));

Wondering, how I’ve made my import looking like the one from the library? Check out one of my previous articles:

Now, when we give it a try and navigate to the URL with the dynamic parameter, our state will look like this:

We need to somehow access the data from our store. Let’s create selectors, so we can actually use our RouterStore and feel the difference. Following our naming convention, which I’ve presented in previous articles selectors should live within router.selectors.ts file:

import { createFeatureSelector, createSelector } from '@ngrx/store';
import { RouterState } from '@shared/models';
import { RouterReducerState } from '@ngrx/router-store';

export const selectRouterState = createFeatureSelector<RouterReducerState<RouterState>>('router');

export const selectParams = createSelector(
selectRouterState,
router => router?.state?.params
);

export const selectQueryParams = createSelector(
selectRouterState,
router => router?.state?.queryParams
);

export const selectUrl = createSelector(
selectRouterState,
router => router?.state?.url
);

These are the most needed selectors for entire store implementation!

To comply with our architectural approach, we just need a FacadeService which will be injected when data from the store will be needed. Let’s create router.facade.ts service:

import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { Params } from '@angular/router';
import { selectParams, selectQueryParams, selectUrl } from '@store/router/router.selectors';

@Injectable({
providedIn: 'root',
})
export class RouterFacadeService {
constructor(private readonly store: Store) {}

readonly getParams$: Observable<Params> = this.store.select(selectParams);

readonly getQueryParams$: Observable<Params> = this.store.select(selectQueryParams);

readonly getUrl$: Observable<string> = this.store.select(selectUrl);
}

💡 If you find yourself using this code over and over for multiple projects, consider using Bit to extract it from your codebase into its own component, which you can test, version, document and then publish to Bit’s component-sharing platform from where you (or others) can import it into your projects.

Learn more:

We can inject it anywhere within our app and forget about ActivatedRoute or ActivatedRouteSnapshot entirely :-)!

Our router-store file structure should look like the below:


-router-store
|-index.ts
|-router.facade.ts
|-router.selectors.ts
|-router-state-serializer.ts
|-... // do not forget about unit tests files with .spec.ts! :)

That was easy, wasn’t it? :-)

Build composable Angular apps with reusable components, just like Lego

Bit is an open-source toolchain for the development of composable software.

With Bit, you can develop any piece of software — a modern web app, a UI component, a backend service or a CLI script — as an independent, reusable and composable unit of software. Share any component across your applications to make it easier to collaborate and faster to build.

Join the 100,000+ developers building composable software together.

Get started with these tutorials:

→ Micro-Frontends: Video // Guide

→ Code Sharing: Video // Guide

→ Modernization: Video // Guide

→ Monorepo: Video // Guide

→ Microservices: Video // Guide

→ Design System: Video // Guide

--

--

JavaScript/Typescript experience with biggest corporates and scalable software, with focus on reactive / performance programming approach / high code quality.