Using replaceUrl To Persist Search Filters In The URL Without Messing Up The Browser History In Angular 7.2.14
I am a strong believer that when building a web-based application, as much state should be pushed into the URL as possible. This allows the Browser's Back Button to provide a more intuitive experience for the end-user. Of course, this is only true if the Browser's history is well aligned with the user's mental model of the application interactions. To strike a nice balance on search-oriented pages, I like to push the search criteria into the URL; however, I like to do this with the "replaceUrl" option in the Angular 7.2.14 Router so that the Browser's history doesn't grow with every single change to the page's search criteria.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
To explore this concept, I've created a simple "List-Detail" Angular app in which the list page has a "filter" input. As the user types into the filter input, I want to both narrow the list of visible results as well as persist the filter to the currently-active Route parameters. Then, if the user clicks through to one of the visible records and then navigates using the Browser's Back Button, I want to return the user to the filtered list from whence they came.
If you're used to working with "State Stores", your first instinct might just be to persist the filter to a Store; and then, retrieve the filter from the store when the user navigates back to the List page. This is certainly a viable solution. But, I think it suffers from a few drawbacks:
The List page is not shareable. Part of the benefit of driving state into the URL is that the URL becomes easily shareable. Meaning, you can Copy-Paste the URL to another user and that other user will be able to see what you see because the app will be able to pull state out of the shared URL.
It's hard to differentiate between navigation events. If you're persisting search state to a Store, then you have to programmatically differentiate between a "Back Button" navigation (Pop State), an explicit navigation (Push State), and a page refresh. This is because you don't want to re-render the search if the user explicitly navigates to the List page; but, you do want to re-render the search if the user navigates via the Back Button or a Refresh.
If you push the search criteria into the URL, then these hurdles magically disappear. The URL becomes the source of truth and the type of navigation becomes irrelevant.
When pushing state into the URL, we have two options: we can push the new URL onto the history stack; or, we can replace the current item in the history stack. Both of these methods make sense in different contexts.
If the user is explicitly submitting a Form in order to execute a search, I believe that pushing the resultant state onto the history stack makes sense as each form submission represents a true "navigation" event. However, if the search criteria is being dynamically applied, such as in the case of this demo, then I think replacing the history item makes more sense.
To see this in action, here's the Angular Component for the list view. This view provides a single Filter input and a list of results. As the user types into the Filter input, the filter value is both persisted to the URL (using the "replaceUrl" option) and used to narrow down the list of rendered results:
// Import the core angular services.
import { ActivatedRoute } from "@angular/router";
import { Component } from "@angular/core";
import { Router } from "@angular/router";
// Import the application components and services.
import { PeopleService } from "./people.service";
import { Person } from "./people.service";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface Result {
content: string;
isVisible: boolean;
person: Person;
}
@Component({
selector: "my-people-list",
styleUrls: [ "./people-list.component.less" ],
template:
`
<h2>
People List
</h2>
<div>
<input
type="text"
name="filter"
[(ngModel)]="form.filter"
(ngModelChange)="applyFilter()"
placeholder="Search..."
autocomplete="off"
autofocus
class="filter"
/>
</div>
<ul class="items">
<li
*ngFor="let result of results"
class="items__item"
[class.items__item--hidden]="( ! result.isVisible )">
<a routerLink="/people/{{ result.person.id }}/detail">
{{ result.person.name }}
</a>
</li>
</ul>
`
})
export class PeopleListComponent {
public form: {
filter: string;
};
public results: Result[];
private activatedRoute: ActivatedRoute;
private peopleService: PeopleService;
private router: Router;
// I initialize the people-list view component.
constructor(
activatedRoute: ActivatedRoute,
peopleService: PeopleService,
router: Router
) {
this.activatedRoute = activatedRoute;
this.peopleService = peopleService;
this.router = router;
this.form = {
filter: ( activatedRoute.snapshot.params.filter || "" )
};
this.results = [];
}
// ---
// PUBLIC METHODS.
// ---
// I apply the current filter to the view-model.
// --
// NOTE: This is getting called after every (input) / (ngModelChange) event on the
// form filter.
public applyFilter() : void {
this.applyFilterToResults();
this.applyFilterToRoute();
}
// I get called once after the inputs have been bound for the first time.
public ngOnInit() : void {
this.peopleService.getPeople().then(
( people ) => {
this.results = people.map(
( person ) => {
return({
content: person.name.toLowerCase(),
isVisible: true,
person: person
});
}
);
// Now that we have the initial results populated, let's apply any
// filtering that was predefined by the route.
this.applyFilterToResults();
},
( error ) => {
console.warn( "Oh noes!" );
console.error( error );
}
);
}
// ---
// PRIVATE METHODS.
// ---
// I apply the filter to the list of people, setting the "isVisible" flag based on
// the content match of each result item.
private applyFilterToResults() : void {
var filter = this.form.filter.toLowerCase();
for ( var result of this.results ) {
result.isVisible = ( filter )
? result.content.includes( filter )
: true
;
}
}
// I apply the filter to the route, persisting the current filter value to the
// current route's parameters.
private applyFilterToRoute() : void {
this.router.navigate(
[
{
filter: this.form.filter
}
],
{
relativeTo: this.activatedRoute,
// NOTE: By using the replaceUrl option, we don't increase the Browser's
// history depth with every filtering keystroke. This way, the List-View
// remains a single item in the Browser's history, which allows the back
// button to function much more naturally for the user.
replaceUrl: true
}
);
// In order to more clearly illustrate how the "replaceUrl" is affecting the
// browser's history, let's all update the document title as well - this value
// will show up in the back-button drop-down menu.
document.title = `Search: ${ this.form.filter }`;
}
}
As you can see, with each keystroke, the filter is persisted as a Route Parameter using the following command:
this.router.navigate(
[
{
filter: this.form.filter
}
],
{
relativeTo: this.activatedRoute,
replaceUrl: true
}
);
If we omitted the "replaceUrl" option and started searching for the Person, "Tricia", our Browser's History would end up looking like this:
Search: Tricia
Search: Trici
Search: Tric
Search: Tri
Search: Tr
Search: T
This is because each keystroke would update the Route Parameter, which would be seen as a unique navigation event. However, since we are using the "replaceUrl" option, searching for and then navigation to the "Tricia" record leaves our Browser History looking like this:
As you can see, by using "replaceUrl", only a single search state is appended to the Browser's history. Now, if the user clicks the Back Button, they will be taken to the desired search results; but, in such a way that a subsequent click of the Back Button works just as the user would expect it to: taking the user back to the Home page.
When the browser's back button doesn't work in the way that the user expects it to, I like to believe that this is a shortcoming of the Application, not of the user's mental model. As such, I like to drive as much state as possible into the URL of an Angular application - part of why I love "Auxiliary Routes" so much. However, when it comes to something like search criteria, using the "replaceUrl" option of the Router can lead to a more natural Back Button experience by not growing the History stack unnecessarily. Of course, each context is different, so this isn't a hard-and-fast rule - it's just an option that can be leveraged when necessary.
Want to use code from this post? Check out the license.
Reader Comments
Great article and interesting approach to this reasonably common problem. The idea of keeping app state in sync with the URL AND making back work as expected is very appealing.
You mentioned not using a state store directly, which certainly makes sense for a small amount of state. Have you considered a technique that would allow one to use a state store and then express an updated url based on the state (i.e. with a selector)? It seems like apply[StateVariable]ToRoute would start to get repetitive/redundant if you have an application that uses lots of state. Could be an interesting follow up article.. :)
@Matt,
Glad you found this interesting. I am not intending to say don't use a state store. I am only saying that things like filter state should not only be in the state store. Essentially, nothing that helps determine where the user "is" in the app should live solely in the store. By keeping it in the URL, you maximize the power of the URL, making it shareable and bookmarkable.
How you integrate your state store with the "web app" is gonna be different for each context and type of store. Some stores believe that the Store should control the Route; I believe the opposite - that the Route is just a source of inputs into the Store that should be managed by the Controllers. But, to each their own -- I don't have enough experience to argue with strong feelings :D
All that to say, for something like a Search Form, I wouldn't even put that in a Store at all. I'd just keep that right as Component State and use
ngModel
. I might pipe the Search action and the Results into a store; but, having the filter value itself in the store probably wouldn't add much value.But, again, that's just my opinion based on how I organize state.
@Matt,
Let me noodle on a possible follow-up that involves a Store; maybe I can find something that feels right.
Hello!
Well timed article. I'm currently learning Angular (7-8) for a starting project and I really had at hearth to implement a sane history management. Most of the SPA I have seen recently are simply broken (as much on respecting browser back/forward expectation than giving a not giving a way to share a state of the application (search results being one common exemple) by url.
Last one had a reimplementation / bypass of history api using session storage...
I had seen recently you other article while searching for this (www.bennadel.com/blog/3533-using-router-events-to-detect-back-and-forward-browser-navigation-in-angular-7-0-4.htm) and I'm happy to read this one. Maybe there is a simple solution to implement all this.
I have currently fallen in NGrx rabbit hole and while I currently think I can do something with it I'm also a bit horrified by the complexity and maintenance risk that it introduce (and also the learning overhead that i will pass to other collegues).
Also, documentation on good pratice is really scarce and found almost no demo of good implementation. The only one that seem to make sense is this one : https://github.com/contentacms/contenta_angular (See "ngrx 4" comment in the README.MD).
That have a bit of code to synchronise the url and store, Seems to work adequately except a few details (I would prefer update on submit by ex or something like that).
I'm currently trying to redo it more closer to my domain and also using our app and lastest version of NGRX but I still have the gut feeling that it should be simpler...
ps : Two other articles on the subject of navigations :
https://medium.freecodecamp.org/why-i-hate-your-single-page-app-f08bb4ff9134
Hello,
In your demo, you have your search form and result list in the same page. filters are in the page.
In my application, there will be a separate search page with many parameters and a separated page for results. Would it make sense then to keep the parameters exposed in url of each routes?
Ex. :
search-form?filter=test
result-list?filter=test
It's easy to be tempted to hide the parameters when using a store but at the same time I think that it could be worth having a way to return to a state of the form as much as the result list (equivalent of form + auto-submit).
In the contenta demo the manipulation of the url is done in the contexte of a specific component / route, so I would have to find a shared way to do it (there is also a validation to do long term to check if we can still handle parameters from a old user bookmark by exemple (parameter name change or else)).
Also, in another project we had used base64 encoded url (from a json representation of the parameters) to encode one search parameters, it's a bit less user friendly but it helps the handling of free form text. I have implemented par of it using the contentat source demo but ended in a infinite loop when I tried to reassing the decoded value to the observable state (I understand why but not how / where I should do it instead)!
@Eric,
I am happy to hear that you are finding these posts interesting. With regard to the "form" vs. "results" page, when your workflow is spread over two "views", then in that case, I would probably just expose the "parameters" on the results page. So, the workflow would go from:
search-form
submits toresults-list?filter=test
This is essentially mimics an "old school"
<form method="GET">
submission. By using theGET
method, it just adds all of the form-fields to the URL instead of as part of the request body. This way, you can bookmark the results or copy-paste them to another user via Email or Chat.Of course, you could still add the
filter
to thesearch-form
view if you wanted to pre-populate the form when the user goes "Back". But, since the form is no longer the primary "shareable item" (the results are), the method of pre-population is less important. If you wanted to keep the form-fields in a Store and pre-populate it that way, it wouldn't really matter.That said, I think the URL-based approach is the easiest to manage since you never have to worry about how the user got there (ie, Back button vs. explicit navigation) -- you just have to let the URL drive the state. After all, as you said, state management becomes a real "Rabbit hole".
Thanks for the follow-up!
I worked on proof of concept using Ngrx and after discussing with a collegue of all the corner case involved I came back to the simpler proof of concept like the one you have described in your article.
It was just a few lines to get something basic working but I'm blocked on scenarios related to reload (think unloaded data on mobile) (but it's not specific to the implementation, the solution with Ngrx had also this unknown).
It would be difficult to describe my application screen here but I have a case where I would like to keep track of a state so that it's persistent to page reload but not explosed in the url (a backlink that is not a "browser back").
Angular is not working as expected. It's possible to assign and pass the state (recent fontionnality I think) :
this.router.navigateByUrl('/somewhere', { state: { somestate: "test" } });
I can read the state in the other page component but if I reload the page the state is lost.
I have asked a question about that on Angular Github and will see if it's me that is not using it correctly!
Definitively not a trivial problem!