Using the url for state with Angular
February 29, 2020 • ⏱ 6 min readA 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 bothgenre
andplatform
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! 🙌