Using the URL as State: Query Params, withComponentInputBinding and a Working Back Button
Filters that must be shareable, survive a reload and work with Back belong in the URL: query params are bound to inputs with withComponentInputBinding(), validated with transform and changed only through router.navigate. We move Bookstore's catalog to the URL and show with real output a shared link, invalid values, the Back button, how replaceUrl keeps the history clean in a search box, and Angular 22's 'always' paramsInheritanceStrategy.
Version note: The examples in this post were run with Angular 22.2, NgRx 22.0.1 (@ngrx/signals), TypeScript 6.0 and Node.js 24 (Angular 22.2 projects use TypeScript 6.0); browser behaviour and history entries were observed with Playwright 1.65 in headless Chromium against the mock Bookstore API in the snapshot.
Predict First
We added a search box to the catalog of bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series. The text typed into the box is written to the URL as ?q=, and the book list follows the URL. The first version of the search method:
protected search(q: string): void {
this.#updateQuery({ q: q || null, page: null });
}
The user opens the home page, clicks the "Classics" category, types "pri" into the search box and, unhappy with the result, presses the browser's Back button once. Which address do they land on: the home page before the category was chosen, classics without a search, or somewhere else? How many entries did those three letters add to the browser history? Write down your prediction; the answer is in the "replaceUrl" section.
In the previous two posts the catalog's page number lived in a linkedSignal in memory; it went back to 1 on reload and could not be shared in a link. In this post we move the catalog filters (category, search, sort, page) to the URL: binding query params to inputs with withComponentInputBinding(), validating the strings that come from the URL, router.navigate with queryParamsHandling: 'merge', replaceUrl in the search box, Angular 22's queryParams and unmatchedInputBehavior options, and the change to the paramsInheritanceStrategy default. The router basics are in the Angular series post "Angular Routing Guide: RouterModule, Route Parameters and Child Routes".
Why the URL? Which State Belongs There?
In the first post's decision tree, the URL was the answer to "must it survive a shared link, reload and Back?". When you put a piece of state in the URL, you get four things without writing extra code: when the user copies the address and sends it, the other person sees the same screen; filters are not lost on reload; the Back and Forward buttons go to the previous filter as the user expects; and search engines and analytics tools see each filter combination as a separate address.
Not every piece of state belongs in the URL. Good candidates are values that make sense when shared and are enough to reproduce the same screen: filters, search text, sorting, page, the selected tab. Bad candidates are transient UI state (an open dialog, hover), half-finished form input, and personal or sensitive data. The URL ends up in logs, the browser history, the Referer header and screen shares; an email address or a token does not belong there. That is why the catalog's cover dialog does not go into the URL and stays a linkedSignal.
Binding a Query Param to an input()
The app was already running with provideRouter(routes, withComponentInputBinding()); category has arrived this way since the first post. The catalog page now takes four query params as inputs:
export class CatalogPage {
readonly #router = inject(Router);
// URL state: ?category=classics&q=emm&sort=price&page=2, bound by withComponentInputBinding()
// and validated by the transform functions (the URL can contain anything)
readonly category = input<string>();
readonly q = input('', { transform: toSearch });
readonly sort = input<SortOrder, string | undefined>('title', { transform: toSort });
readonly page = input<number, string | undefined>(1, { transform: toPage });
// Server state: derived from the URL state, fetched again when any of it changes;
// the previous page stays on screen while the next one loads (NgRx resource extension)
protected readonly books = extendResource(
httpResource<BookPage>(() => {
const params: Record<string, string | number> = { page: this.page(), sort: this.sort() };
const category = this.category();
if (category) params['category'] = category;
if (this.q()) params['q'] = this.q();
return { url: `${apiUrl}/books`, params };
}),
withPreviousValueOnLoading(),
);
On every navigation, the router collects the route's data (path parameters, query params, matrix parameters, data and resolver results) and writes it into inputs with the same names. According to the Angular documentation, when the same key exists in several sources, the priority order is: the route's resources map, data and resolvers, path and matrix parameters, and query params last. So on a route with an :isbn path parameter, an ?isbn= query param cannot reach the input. The page linkedSignal from the previous posts is gone entirely: the page number's owner is now the URL. The resource is still a derivation: when one of the four inputs changes, a new request.
Angular 22 added an options object to withComponentInputBinding. queryParams: false turns off binding query params; only path parameters and route data go to inputs. That prevents components that have an input with the same name as a query param from picking up a value from the URL by accident. unmatchedInputBehavior says what happens to inputs with no match in the route data: the default 'alwaysUndefined' writes undefined to every unmatched input (so that a stale value does not stay on screen); 'undefinedIfStale' writes undefined only to an input that previously received a value from the router, and leaves inputs that never matched alone. Bookstore's catalog runs with the default, and that is an advantage: when ?category= is removed from the URL, the category input really becomes undefined.
The URL Is Input: Validation with transform
A query param always arrives as a string or undefined, and the user can type anything into the address bar. Conversion and validation live in a separate file, as pure functions:
// The URL is user input: every query param arrives as a string (or undefined) and may be anything
export type SortOrder = 'title' | 'price';
export function toPage(value: string | undefined): number {
const page = Number(value);
return Number.isInteger(page) && page >= 1 ? page : 1;
}
export function toSort(value: string | undefined): SortOrder {
return value === 'price' ? 'price' : 'title';
}
export function toSearch(value: string | undefined): string {
return (value ?? '').trim().slice(0, 50);
}
These functions are passed to the inputs' transform option; the type parameters of input<number, string | undefined> say "a string comes in from outside, a number is used inside". When an invalid value arrives, we fall back to the default instead of failing: ?page=abc is page 1, ?sort=drop-table sorts by title. The request to the API always goes out with the cleaned value; the server does its own validation too, but the client not sending the broken value as is keeps both the logs and the cache clean.
Writing to the URL: router.navigate, queryParamsHandling and the Back Button
The reading side is the inputs, the writing side is the router. The component does not change its own state; it changes the URL, and everything else follows the URL:
// Each change writes the URL; the inputs, and through them the resource, follow the URL
protected goTo(page: number): void {
this.#updateQuery({ page: page === 1 ? null : page });
}
protected sortBy(sort: string): void {
this.#updateQuery({ sort: sort === 'title' ? null : sort, page: null });
}
protected search(q: string): void {
// typing is one step for the user: replace the history entry instead of adding one per key
this.#updateQuery({ q: q || null, page: null }, { replaceUrl: true });
}
#updateQuery(queryParams: Record<string, string | number | null>, extras = {}): void {
void this.#router.navigate([], { queryParams, queryParamsHandling: 'merge', ...extras });
}
router.navigate([], ...) stays on the same route and changes only the query params. queryParamsHandling: 'merge' merges the given params with the current ones; null removes a param from the URL. When the sort or the search changes, page: null resets the page too: the job the linkedSignal did in the previous post is now done by this single param. Not writing default values (page 1, sorting by title) to the URL is deliberate too: the same screen should have a single address. The category links are written with the same logic:
<nav class="categories">
<a routerLink="/" [queryParams]="{ category: null, page: null }" queryParamsHandling="merge">All</a>
@for (c of categories.list.value(); track c.slug) {
<a routerLink="/" [queryParams]="{ category: c.slug, page: null }" queryParamsHandling="merge">
{{ c.name }}
</a>
}
</nav>
<p>
<input class="search" placeholder="Search titles" [value]="q()" (input)="search($any($event.target).value)" />
<select class="sort" [value]="sort()" (change)="sortBy($any($event.target).value)">
<option value="title">Title</option>
<option value="price">Price</option>
</select>
</p>
Playwright first opened a shared link directly, then typed invalid values into the address bar, and finally clicked a category and "Next" and pressed Back twice. Each line has the URL, the summary and the listed books:
# a shared link, opened in a new tab
0.1 s request: GET /categories
0.1 s request: GET /books?page=2&sort=price&category=classics
open link url /?category=classics&sort=price&page=2 4 books, page 2 of 2: Emma, Middlemarch
# invalid values typed into the address bar
0.8 s request: GET /categories
0.8 s request: GET /books?page=1&sort=title
page=abc url /?page=abc&sort=drop-table 6 books, page 1 of 3: Dune, Emma
1.5 s request: GET /categories
1.6 s request: GET /books?page=1&sort=title
page=-3 url /?page=-3 6 books, page 1 of 3: Dune, Emma
# clicks, then the Back button
2.3 s request: GET /categories
2.3 s request: GET /books?page=1&sort=title
open / url / 6 books, page 1 of 3: Dune, Emma
3.1 s request: GET /books?page=1&sort=title&category=classics
click Classics url /?category=classics 4 books, page 1 of 2: Emma, Frankenstein
3.8 s request: GET /books?page=2&sort=title&category=classics
click Next url /?category=classics&page=2 4 books, page 2 of 2: Middlemarch, Pride and Prejudice
4.5 s request: GET /books?page=1&sort=title&category=classics
Back url /?category=classics 4 books, page 1 of 2: Emma, Frankenstein
5.2 s request: GET /books?page=1&sort=title
Back url / 6 books, page 1 of 3: Dune, Emma
The shared link opened the screen the sender saw: classics, by price, page 2. The invalid values stayed in the URL but never reached the request (page=1&sort=title). At each step, Back went to the previous filter, and the list was fetched again by following the URL. We wrote not a single line of history management code for this; the router and the browser history already do that job.
replaceUrl: Not a History Entry per Key
The first version from "Predict First" did a normal navigation on every key. The user entered classics, typed "pri" and pressed Back once:
before typing history.length 3 | /?category=classics
after "pri" history.length 6 | /?category=classics&q=pri | 1 books, page 1 of 1
after one Back history.length 6 | /?category=classics&q=pr | 1 books, page 1 of 1
Three letters added three entries to the history (history.length from 3 to 6), and Back took the user not to classics but to q=pr. Getting out of the search takes three presses of Back. The same scenario with the replaceUrl: true in the real code:
before typing history.length 3 | /?category=classics
after "pri" history.length 3 | /?category=classics&q=pri | 1 books, page 1 of 1
after one Back history.length 3 | / | 6 books, page 1 of 3
The history length did not change, because every key overwrote the current entry. There is a detail here: since the first key also overwrote the classics entry, a single Back went straight to the home page; classics without a search is not in the history. Which one is right is a product decision. In Bookstore, searching counts as "narrowing the same list", so this was accepted; if the search should have its own history step, you can do a normal navigation on the first key and replaceUrl on the following ones. Sort and page changes, on the other hand, are deliberately normal navigations: the user expects Back to return to the previous page.
Child Routes and paramsInheritanceStrategy
We added a "Share" child route to the book details page: the address /books/9780441172719/share opens a small panel that shows the share link. :isbn belongs to the parent route:
{
path: 'books/:isbn',
component: BookDetailsPage,
children: [{ path: 'share', component: SharePanel }],
},
import { Component, input } from '@angular/core';
@Component({
selector: 'app-share-panel',
template: `<p class="share">Share this book: https://bookstore.example.com/books/{{ isbn() }}</p>`,
})
export class SharePanel {
// :isbn belongs to the parent route; the child gets it through params inheritance
readonly isbn = input<string>();
}
Can the child component get isbn from the parent's parameter? In Angular 22, yes:
Dune | Share this book: https://bookstore.example.com/books/9780441172719
Angular 22.0 changed the router's paramsInheritanceStrategy default from 'emptyOnly' to 'always', and the changelog lists it as a breaking change. With 'emptyOnly', a route inherited the parent's parameters only if its own path was empty or the parent had no component. Turning the old behaviour back on:
provideRouter(
routes,
withComponentInputBinding(),
withAutoCleanupInjectors(),
// the behaviour before Angular 22
withRouterConfig({ paramsInheritanceStrategy: 'emptyOnly' }),
),
Dune | Share this book: https://bookstore.example.com/books/
The panel was rendered with an empty ISBN: the share path is not empty and the parent has a component, so there is no inheritance. That is why child components written before Angular 22 often contain code that reads parameters through inject(ActivatedRoute).parent. After upgrading to Angular 22 that code keeps working, but new code does not need it. Watch the other direction too: a child that used to inherit nothing can now receive a value for an input with the same name as a parent parameter.
In Production
Every change in the URL means a new request for the resources that follow it. In the search box, every key sends a request; because httpResource cancels the previous request, no wrong result stays on screen, but the server still sees every key. Acceptable for Bookstore's small list; for a large catalog you need a short wait before writing to the URL. The search box with debounceTime in the RxJS series post "RxJS in Angular 22: HttpClient, Router and Forms Observables, Signal Interop and Subscription Management", or Angular 22's experimental debounced(), are two ways to do it.
The URL's length and shape are a contract. Shared links live for years in emails, chats and search engine indexes; renaming a query param (search instead of q) breaks old links. Keep param names short and stable, don't write defaults to the URL, and if needed keep reading the old name for a while and convert it to the new one. A single address for the same screen also keeps page views from being scattered in analytics; the canonical address and the SEO side are topics for the Content SEO series.
Validate on the server too. The transform on the client only protects your own app; someone calling the same API directly can send page=-3. That is why our mock API has the same rule (Math.max(1, ...)) on the server as well.
Common Pitfalls
Using the string from the URL without validating it
Writing Number(this.route.snapshot.queryParamMap.get('page')) and moving on produces NaN for ?page=abc and 0 for ?page=. That value goes into the request; "NaN" may appear on screen, or the server returns 400. Validation belongs at the single point where the value enters the app: the input's transform.
Managing the same filter from two sources
Keeping a filter both in the URL and in a store, and copying one into the other with an effect, creates two sources of truth. When the user presses Back, the URL changes but the store keeps the old value; a button that updates the store forgets to change the URL. The rule is simple: a piece of state has one owner. If the filter is in the URL, the store does not hold it; it reads the input or signal derived from the URL, and whoever wants to change it changes the URL.
Writing transient state to the URL
Writing the cover dialog to the URL as ?cover=dune may look like a "shareable dialog", but it turns the Back button into a switch that opens and closes the dialog. Test whether a piece of state goes into the URL with the question "if someone sent me this address, would I want to see this screen?".
A normal navigation on every key
As the output above shows, every key adds a history entry and the Back button becomes unusable. While typing, replaceUrl: true.
A Small Experiment
★☆☆ Add a rule to toSearch: search text shorter than 2 characters is ignored. When you type "p" into the box, does q=p appear in the URL? Should it?
★★☆ In the search box, do a normal navigation on the first key and replaceUrl on the following keys. What information do you need to keep for that, where, and what kind of state is it? Verify by measuring history.length with Playwright.
★★★ Add a "price range" filter to the catalog (?min=200&max=400). What should happen if min is greater than max: swap the values, or ignore both? Write a function that validates the two params together; why are separate transforms not enough for this?
Block A ends here: the kinds of state, a signal-based service store, server state and URL state. In the next post we move to NgRx: we turn the CartStore we wrote by hand in the second post into an NgRx Signal Store and look at which parts of the hand-written version signalStore, withState, withComputed, withMethods, patchState, withHooks and withProps replace.