typescript xml 123 lines · 3 tabs

Sync Angular Reactive Form State to URL Query Params With a Route-Aware Service

Shared by codesnips Aug 2026
3 tabs
import { Injectable } from '@angular/core';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { FormGroup } from '@angular/forms';
import { Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, take } from 'rxjs/operators';

export interface SyncOptions {
  debounce?: number;
  defaults?: Record<string, unknown>;
}

@Injectable({ providedIn: 'root' })
export class UrlFormSyncService {
  constructor(private router: Router, private route: ActivatedRoute) {}

  bind(form: FormGroup, opts: SyncOptions = {}): Subscription {
    const defaults = opts.defaults ?? {};

    this.route.queryParams.pipe(take(1)).subscribe((params) => {
      form.patchValue(this.fromQueryParams(params, defaults), { emitEvent: false });
    });

    const sub = form.valueChanges
      .pipe(
        debounceTime(opts.debounce ?? 300),
        map((value) => this.toQueryParams(value as Record<string, unknown>, defaults)),
        distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
      )
      .subscribe((queryParams) => {
        this.router.navigate([], {
          relativeTo: this.route,
          queryParams,
          queryParamsHandling: 'merge',
          replaceUrl: true
        });
      });

    return sub;
  }

  private toQueryParams(value: Record<string, unknown>, defaults: Record<string, unknown>): Params {
    const out: Params = {};
    for (const key of Object.keys(value)) {
      const v = value[key];
      const isEmpty = v === '' || v === null || v === undefined;
      const isDefault = defaults[key] !== undefined && String(v) === String(defaults[key]);
      out[key] = isEmpty || isDefault ? null : String(v);
    }
    return out;
  }

  private fromQueryParams(params: Params, defaults: Record<string, unknown>): Record<string, unknown> {
    const result: Record<string, unknown> = { ...defaults };
    for (const key of Object.keys(params)) {
      const raw = params[key];
      result[key] = typeof defaults[key] === 'number' ? Number(raw) : raw;
    }
    return result;
  }
}
3 files · typescript, xml Explain with highlit

This snippet shows a common Angular pattern: keeping a reactive form's state mirrored in the URL query string so that filters and searches become shareable, bookmarkable, and survive a page reload. The logic lives in a route-aware service rather than in the component, which keeps the component thin and lets the same sync behavior be reused across filter panels.

In UrlFormSyncService, the core method bind wires a FormGroup to the router in both directions. The form-to-URL direction listens to form.valueChanges, applies a debounceTime and distinctUntilChanged (comparing serialized JSON) so that rapid keystrokes collapse into a single navigation, and calls router.navigate with queryParamsHandling: 'merge'. Merging is important because it preserves unrelated params owned by other features, and replaceUrl: true avoids polluting browser history with an entry per keystroke. Empty or default values are stripped in toQueryParams so the URL stays clean rather than carrying ?q=&page=1 noise.

The URL-to-form direction reads route.queryParams once on bind and calls form.patchValue with emitEvent: false. That flag is the subtle part: without it, patching the form from the URL would re-trigger valueChanges, causing a feedback loop of navigations. Coercion in fromQueryParams handles the fact that query params are always strings, converting page back to a number and defaulting missing keys.

The bind method returns a Subscription (actually a merged teardown) so callers control the lifecycle. In ProductFilterComponent, the form is a typed FormGroup, and bind is invoked in ngOnInit; the returned subscription is stored and unsubscribed in ngOnDestroy to prevent leaks. Because the service is providedIn: 'root' but the subscription is component-scoped, one service instance can safely serve many components.

This approach trades a little indirection for real benefits: URLs become the single source of truth for view state, back/forward navigation works naturally, and server-side rendering can hydrate filters directly from the request URL. The main pitfalls to watch are the emit-loop guard and debounce tuning; too short a debounce spams navigation, too long feels laggy. It is a good fit whenever a filter, search, or pagination control should be deep-linkable.


Related snips

Share this code

Here's the card — post it anywhere.

Sync Angular Reactive Form State to URL Query Params With a Route-Aware Service — share card
Link copied