Skip to content

Signal-based query params: (w/ batch signal updates into a single atomic URL write) #69802

Description

@glimps-fde

Which @angular/* package(s) are relevant/related to the feature request?

router

Description

Context: I built a small utility at work to two-way bind a signal value to a query param in the url. This is useful to store filters on a data table as url data and restore it when navigating or to share a link. It also apply to local config for pages that we want to embed in the url for sharing purposes. It's currently in use. We are fully in the big signal era of angular, this is generally applicable and so much useful that I want to share it. Also this is not trivial and I got it working well so I hope my design would inspire you making a native version.

My research on the subject showed that this subject is quite poorly addressed. The closest thing I found is this issue where the comment I link says it is (was ?) on the roadmap and in the top 5 prioritized project (#59877 (comment)). So I'd like to put one design constraint on the record before the API shape settles.

The problem: independent per-param writes race on the URL.

Most takes on query-param signals bind one signal to one param, each owning its own URL write. That's ergonomic with a single param, but breaks down as soon as several params change in the same tick — e.g. a "clear filters" button resetting search, page and category at once:

clear() {
    this.search.set("");
    this.page.set(1);
    this.category.set(null);
}

If each signal writes the URL independently, that's three writes in one change-detection pass. With queryParamsHandling: 'merge', each write reads the current URL, merges its one key, and writes back. Two consequences:

  1. Churn — the URL (and Location/history) is rewritten N times for one logical change.
  2. Lost updates — depending on timing, a later write can read a URL that doesn't yet reflect an
    earlier one, and clobber it: you end up with a URL missing a param you did set.

Debouncing hides the churn but adds latency and is still not atomic. So here is my proposed solution

Proposed solution

First I suggest a very simple API : queryParamSignal<T>(key: string, initalValue: T, options?: ...): WritableSignal<T> (link to the implementation below)

This gives you back a writable signal which value is synchronized to a query param of a given key. It is two ways meaning that a navigation changing a param would update the signal. options is a complex type that depends on the type T. It is an object with a flag for array values, and a parse and stringify function to parse/serialize value from and to a query param value. The API and typing is built to be as minimal as possible to use while giving great typing.

In use it look like this :

@Component({ ... })
export class MyComponent {
    // string -> options optional
    search = queryParamSignal("q", "");

    // number -> parse required (compile error if you forget it)
    page = queryParamSignal("page", 1, { parse: (v) => Number(v) || 1 });

    // string[] -> array:true; empty string dropped from the URL
    tags = queryParamSignal("tags", [] as string[], {
        array: true,
        stringify: (v) => (v.length ? v : null),
    });
}

To resolve the atomic multi-param case I use a shared service. Params signals don't write the URL themselves, they register into the service, and a single effect reads every registered signal, assembles one queryParams object, and performs one location.replaceState. Since signals are glitch-free, N updates in one tick collapse into one atomic URL write. I also used the location.replaceState to avoid triggering navigation and write in signals back.

To avoid bloating this issue I link the code to my full implementation of this API : https://gist.github.com/glimps-fde/a0a3ad3831133aed277f537c5bb24ad6

This currently fits my internal use case; just works, and it is a very powerful pattern for us. If the Angular team sees value in the approach but wants it to behave differently (navigation semantics, collision policy, API shape, edge cases I haven't hit), I'm happy to iterate on the prototype based on your feedback, or to bring any of this into the upcoming router-signals design discussion when it opens.

Alternatives considered

  • Per-signal URL writes : ergonomic, but subject to the churn/lost-update race described above when several params change in the same tick.
  • Debouncing the writes: hides the churn but adds latency and is still not atomic — the race becomes less likely, not impossible.
  • toSignal(route.queryParams) + manual router.navigate calls: read-only side is fine, but the write side pushes the batching problem onto every call site.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions