Using the url for state with Angular

February 29, 2020 • ⏱ 6 min read

A pretty common use-case for storing values in the url in the form of query strings are filters. Storing filters in the url ensures that everyone with the link will see the same filterable content in their browsers.

This is really great for ecommerce where people will often discuss with friends or ask for help before purchasing a product, and in doing so, might send a link of their filtered products along to their friends.

While it may seem like a small thing, it´s actually a really great user experience to store product filters in the url.

If users refresh their browser by hitting F5, they will also return back to the same screen they were on before. Neat!

But what about single page applications? 🤔

Typically, in a SPA, routing happens on the client. Meaning our frameworks or libraries hook into the browser history, altering the state and updating the url without a full page refresh. There is no roundtrip to the server and the whole page does not need to be rendered all over, which in itself, is also really nice!

However, since the url in this case is really only responsible of getting the application resources to the user upfront, it is not used for fetching any related data for the route.

In Angular we normally use services to fetch data. These services are then imported into our components which use these services to fetch data and render the view:

@Component({
  selector: 'product-list',
  template: `
  <ul>
      <li *ngFor="let product of products">{{ product.name }} {{ product.genre }} {{ product.platform }}</li>
  </ul>
  `
})
export class ProductListComponent implements OnInit {
  products: Product[] = [];

  constructor(private service: ProductService) {}

  ngOnInit() {
      this.service.getProducts().subscribe(_products => {
          this.products = _products;
      })
  }
}

The example above is pretty simple. We inject the ProductService and during the ngOnInit lifecycle of the component, we subscribe to getProducts() which returns a list of products when the api call is done. The response, a list of products in our case, is then set to the class field products.

This field is then used in the template to render the list of products in a normal unordered list.

Adding filters 💡

Now lets say that we want to add two types of filters to our product list, genre and platform, to narrow down the list of games.

Instead of storing the filter options on the component or in a service, let us use the url instead and store our filters as query params.

To access the query params we use the ActivatedRoute service within our component. And to update the query params, we simply navigate 🤭 — yeah, it is that simple!

Getting the query params look something like:

constructor(private route: ActivatedRoute) {}

ngOnInit() {
    this.route.queryParams.subscribe(queryParams => {
        /**
         * If the query-string is '?genre=rpg&platform=xbox'
         * the queryParams object will look like
         * { platform: 'xbox', genre: 'rpg }
         * */
    });
}

Depending on where you´re navigating from, setting query parameters can be done in a few different ways:

<button [routerLink]="[]" [queryParams]="{ genre: 'rpg' }" queryParamsHandling="merge">RPG</button>

Here we use the routerLink directive on a button to navigate to the same route as we´re on using [routerLink]="[]" and set the query parameter genre to rpg, using the queryParams property.

We use the queryParamsHandling="merge" here to make sure we merge our queryParams with the already existing queryParams, overriding ones with identical keys.

So clicking another routerLink that has [queryParams]="{ genre: 'racing' }" will directly override our current genre query param, but a routerLink with [queryParams]="{ platform: 'xbox' }" would merge the two into [queryParams]="{ genre: 'rpg', platform: 'xbox' }".

Another option is doing it from within a component method using the router service:

constructor(private router: Router) {}

updateQueryParameters() {
    this.router.navigate(
        [],         {             queryParams: {                 genre: 'rpg'            },             queryParamsHandling: 'merge'         }    );
}

Using the router.navigate function we navigate to the current route [] and set the queryParams object and the queryParamsHandling option, just like we did with the directive.

For the purpose of this example i´ll be using simple links to update the filters like so:

<div class="product-filters">
    <p>genres: </p>
    <a [routerLink]="[]" [queryParams]="{ genre: 'rpg' }" queryParamsHandling="merge">rpg</a>
    <a [routerLink]="[]" [queryParams]="{ genre: 'platformer' }" queryParamsHandling="merge">platformer</a>
    <a [routerLink]="[]" [queryParams]="{ genre: 'racing' }" queryParamsHandling="merge">racing</a>
    <a [routerLink]="[]" [queryParams]="{ genre: null }" queryParamsHandling="merge">clear</a>
</div>

<div class="product-filters">
    <p>platforms: </p>
    <a [routerLink]="[]" [queryParams]="{ platform: 'playstation' }" queryParamsHandling="merge">playstation</a>
    <a [routerLink]="[]" [queryParams]="{ platform: 'switch' }" queryParamsHandling="merge">switch</a>
    <a [routerLink]="[]" [queryParams]="{ platform: 'xbox' }" queryParamsHandling="merge">xbox</a>
    <a [routerLink]="[]" [queryParams]="{ platform: null }" queryParamsHandling="merge">clear</a>
</div>

Above i´ve added an extra button to each list of filters to clear that particular filter from the url, by simply setting null

Now, clicking on the link for platformer under genres, followed by the link to xbox under platforms we end up with a url like this:

localhost:4200/?genre=platformer&platform=xbox

Great, now we can set our filters in the url and have our component listen to these changes using the ActivatedRoute service. So far so good!

Putting it all together

Let us combine our productService with our queryParams observable stream from the ActivatedRoute service to make sure the service is called each time our filters are updated.

@Component({
  selector: 'product-list',
  templateUrl: './product-list.component.html'
})
export class ProductListComponent {

  products$: Observable<Product[]>;
  constructor(private route: ActivatedRoute, private productService: ProductsService) {
    this.products$ = this.route.queryParams.pipe(switchMap(params => {      const filters = {        platform: params.platform || "",        genre: params.genre || ""      };      return this.productService.getProducts(filters);    }));  }
}

We´ve changed our class field products: Product[] to products$: Observable<Product[]> as it will now be an observable stream of data that gets updated when our filters update.

We then listen to changes on the queryParams stream from the ActivatedRoute service and and use switchMap to switch to the productService.getProducts observable, passing in the filters we get from the queryParams.

Since our queryParams could be non-existent we create an object and set default values to "" for both genre and platform to make sure we provide a sensible default.

The updated template for the ProductListComponent now looks like:

<ul>
    <li *ngFor="let product of products$ | async;">{{ product.name }} {{ product.genre }} {{ product.platform }}</li>
</ul>

Now, everytime we update a query parameter using one or more of our filters, the list of products will automatically get updated with new data from the service.

That is pretty much all there is to it! 🎉

An added benefit of using the url for state such as this, is that the component that updates the filters does not need to be directly tied to the component showing the product list. They could live in completely separate parts of our application.

For those using a state management library such as ngrx, this might seem familiar. Updating the query parameters here is much like dispatching an action, and listening to changes in the queryParams state of the url using the router, is like listening to store changes with selectors in ngrx.

Show me the code 💻

I have created an example repository for the ProductListComponent example used in this article, you can find it here: query-params-filter

I hope this has been useful to you in one way or another! 🙌