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;
}
}
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Subscription } from 'rxjs';
import { UrlFormSyncService } from './url-form-sync.service';
interface ProductFilter {
q: FormControl<string>;
category: FormControl<string>;
page: FormControl<number>;
}
@Component({
selector: 'app-product-filter',
templateUrl: './product-filter.component.html'
})
export class ProductFilterComponent implements OnInit, OnDestroy {
readonly form = new FormGroup<ProductFilter>({
q: new FormControl('', { nonNullable: true }),
category: new FormControl('', { nonNullable: true }),
page: new FormControl(1, { nonNullable: true })
});
private syncSub?: Subscription;
constructor(private urlSync: UrlFormSyncService) {}
ngOnInit(): void {
this.syncSub = this.urlSync.bind(this.form, {
debounce: 250,
defaults: { q: '', category: '', page: 1 }
});
}
resetFilters(): void {
this.form.reset({ q: '', category: '', page: 1 });
}
ngOnDestroy(): void {
this.syncSub?.unsubscribe();
}
}
<form [formGroup]="form" class="filter-bar">
<label>
Search
<input type="text" formControlName="q" placeholder="Find products" />
</label>
<label>
Category
<select formControlName="category">
<option value="">All</option>
<option value="books">Books</option>
<option value="electronics">Electronics</option>
</select>
</label>
<label>
Page
<input type="number" formControlName="page" min="1" />
</label>
<button type="button" (click)="resetFilters()">Reset</button>
</form>
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
package com.example.myapp
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
Dependency injection with Hilt
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface FilterState {
search: string
category: string | null
Zustand for lightweight state management
import SwiftUI
struct ContentView: View {
@State private var username = ""
@State private var isLoggedIn = false
@StateObject private var viewModel = LoginViewModel()
SwiftUI declarative UI with state management
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
url: String,
delay: { type: Number, default: 800 },
Stimulus: autosave draft with Turbo-friendly requests
Share this code
Here's the card — post it anywhere.