Reactive router state

A few helpful utilities for getting router state, that I ended up writing often in various apps & power some larger features internally in router-core.

@mmstack/router-corenpm

#url

url is the simplest of the three: a read-only signal that holds the current router URL. It updates after every successful navigation and reflects the URL after redirects, so what you read is where you actually are. It also initializes synchronously with the router's current URL, which means you can read it in a computed or a template on first render without guarding for an empty value.

import { Component } from '@angular/core';
import { url } from '@mmstack/router-core';

@Component({
  selector: 'app-header',
  template: `<nav>Current path: {{ currentUrl() }}</nav>`,
})
export class HeaderComponent {
  protected readonly currentUrl = url();
}

Reach for it whenever a component needs to react to the current path, highlighting a nav item, showing a different header per section, without threading ActivatedRoute subscriptions through the component.

Some navigations do not change the URL string. Landing on / for the first time, an onSameUrlNavigation: 'reload', a redirect that lands you back where you were: in all of these the router navigated, but a signal over the URL string sees no change and does not fire. If you are deriving something from a router-state snapshot, that stale read is a real bug.

navigationEndTick is a counter that increments on every successful navigation, same-URL ones included. Read it inside a computed and that computed recomputes once per navigation regardless of whether the URL text moved. It is the reliable key for anything you pull off router.routerState.snapshot.

const tick = navigationEndTick(inject(Router));

const leaf = computed(() => {
  tick(); // recompute per navigation, even same-URL reloads
  let r = router.routerState.snapshot.root;
  while (r.firstChild) r = r.firstChild;
  return r;
});

#queryParam

queryParam is the read-and-write one. It returns a WritableSignal bound to a single URL query parameter. Reading gives the current value, or null when the param is absent. Setting a value writes it into the URL; setting null removes it. It reacts to external navigation too, so if the URL changes from a link or a back button, the signal updates to match.

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { queryParam } from '@mmstack/router-core';

@Component({
  selector: 'app-search-page',
  imports: [FormsModule],
  template: `
    <input [(ngModel)]="searchTerm" placeholder="Search..." />
    <button (click)="searchTerm.set(null)" [disabled]="!searchTerm()">Clear</button>
    <p>Current search: {{ searchTerm() ?? 'None' }}</p>
  `,
})
export class SearchPageComponent {
  protected readonly searchTerm = queryParam('q');
}

Writes use queryParamsHandling: 'merge', so setting one param leaves the others untouched. There is one sharp edge worth knowing: each set() navigates immediately, rebuilding from the pre-navigation URL. Write two params in the same tick and the second overwrites the first, because it never saw the first write. Pass batch: true when you intend to update several params together and want a single navigation that keeps all of them.

#Typed and tuned params

URL params are always strings, but you rarely want them as strings. The second argument to queryParam lets you type and tune the signal. Provide both parse and serialize and the signal becomes a WritableSignal<T | null>: parse runs on a present param, an absent one reads as null without calling parse, and a serialize that returns null drops the param from the URL. That last detail is how you keep default values out of the URL, page 1 does not need to show up as ?page=1.

// number-typed page param
readonly page = queryParam<number>('page', {
  parse: (v) => {
    const n = parseInt(v, 10);
    return Number.isFinite(n) ? n : null;
  },
  serialize: (n) => (n <= 1 ? null : String(n)), // page 1 keeps the URL clean
});

// debounced type-ahead, no history spam while typing
readonly q = queryParam('q', { replaceUrl: true, debounce: 300 });

The remaining options handle write behavior. replaceUrl writes without pushing a history entry, which is the right call for a type-ahead box you do not want cluttering the back button. debounce delays writes by a number of milliseconds while reads stay instant, so the input feels live but the URL only updates when the user pauses. And route binds the signal to a specific ActivatedRoute instead of the injected one, for the rare case where that matters.