URL'i State Olarak Kullanmak: Query Param'lar, withComponentInputBinding ve Geri Tuşunun Çalışması
Paylaşılması, yenilemede kalması ve geri tuşuyla dönülmesi gereken filtreler URL'de tutulur: query param'lar withComponentInputBinding() ile input'a bağlanır, transform ile doğrulanır ve yalnız router.navigate ile değiştirilir. Bookstore kataloğunu URL'e taşıyor; paylaşılan link'i, geçersiz değerleri, geri tuşunu, arama kutusunda replaceUrl'ün geçmişi nasıl temiz tuttuğunu ve Angular 22'de paramsInheritanceStrategy'nin 'always' olmasını gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2, NgRx 22.0.1 (@ngrx/signals), TypeScript 6.0 ve Node.js 24 ile denenmiştir (Angular 22.2 projeleri TypeScript 6.0 kullanıyor); tarayıcı davranışı ve geçmiş kayıtları Playwright 1.65 ile headless Chromium'da, snapshot'taki sahte Bookstore API'sine karşı gözlenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi bookstore-web'de kataloğa bir arama kutusu ekledik. Kutuya yazılan metin URL'e ?q= olarak yazılıyor, kitap listesi de URL'i izliyor. Arama method'unun ilk hâli:
protected search(q: string): void {
this.#updateQuery({ q: q || null, page: null });
}
Kullanıcı ana sayfayı açıyor, "Classics" kategorisine tıklıyor, arama kutusuna "pri" yazıyor ve sonuçtan memnun kalmayıp tarayıcının geri tuşuna bir kez basıyor. Hangi adrese döner: kategori seçilmeden önceki ana sayfaya mı, aramasız classics'e mi, yoksa başka bir yere mi? Bu üç harf tarayıcı geçmişine kaç kayıt ekledi? Tahminini yaz; cevap "replaceUrl" bölümünde.
Önceki iki yazıda kataloğun sayfa numarası bellekteki bir linkedSignal'daydı; yenilemede 1'e dönüyor, link'le paylaşılamıyordu. Bu yazıda katalog filtrelerini (kategori, arama, sıralama, sayfa) URL'e taşıyoruz: query param'ları withComponentInputBinding() ile input'a bağlamak, URL'den gelen string'i doğrulamak, router.navigate ile queryParamsHandling: 'merge', arama kutusunda replaceUrl, Angular 22'nin queryParams ve unmatchedInputBehavior seçenekleri ve paramsInheritanceStrategy varsayılanının değişmesi. Router'ın temeli Angular serisinin "Angular Routing Rehberi: RouterModule, Route Parametreleri ve Child Route'lar" yazısında.
Neden URL? Hangi State URL'e Girer?
URL, ilk yazıdaki karar ağacında "link, yenileme ve geri tuşunda korunmalı mı?" sorusunun cevabıydı. Bir state'i URL'e koyduğunda dört şeyi ek kod yazmadan alıyorsun: kullanıcı adresi kopyalayıp gönderince karşı taraf aynı ekranı görüyor; sayfa yenilenince filtreler kaybolmuyor; geri ve ileri tuşları kullanıcının beklediği gibi önceki filtreye dönüyor; arama motorları ve analytics araçları her filtre kombinasyonunu ayrı bir adres olarak görüyor.
Her state URL'e girmemeli. İyi aday: paylaşıldığında anlamlı olan ve aynı ekranı yeniden üretmeye yeten değerler, yani filtre, arama metni, sıralama, sayfa, seçili sekme. Kötü aday: geçici UI state'i (açık dialog, hover), yarım kalmış form girişleri, ve kişisel ya da hassas veri. URL log'lara, tarayıcı geçmişine, Referer header'ına ve ekran paylaşımına gider; e-posta adresi ya da token oraya ait değil. Kataloğun kapak dialog'u bu yüzden URL'e girmiyor ve linkedSignal olarak kalıyor.
Query Param'ı input()'a Bağlamak
Uygulama zaten provideRouter(routes, withComponentInputBinding()) ile çalışıyordu; ilk yazıdan beri category bu yoldan geliyordu. Katalog sayfası artık dört query param'ı input olarak alıyor:
export class CatalogPage {
readonly #router = inject(Router);
// URL state: ?category=classics&q=emm&sort=price&page=2, withComponentInputBinding() bağlar
// ve transform fonksiyonları doğrular (URL'de her şey olabilir)
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: URL state'ten türetilir, herhangi biri değişince yeniden çekilir;
// sonraki sayfa yüklenirken önceki sayfa ekranda kalır (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(),
);
Router her navigation'da route'un verisini (path parametreleri, query param'lar, matrix parametreleri, data ve resolver sonuçları) toplayıp aynı isimli input'lara yazıyor. Angular dokümantasyonuna göre aynı anahtar birden fazla kaynakta varsa öncelik sırası şöyle: route'un resources haritası, data ve resolver'lar, path ve matrix parametreleri, en son query param'lar. Yani :isbn path parametresi olan bir route'ta ?isbn= query param'ı input'a ulaşamıyor. Önceki yazılardaki page linkedSignal'ı tamamen kalktı: sayfa numarasının sahibi artık URL. Resource ise hâlâ bir türetme: dört input'tan biri değişince yeni bir istek.
Angular 22, withComponentInputBinding'e bir options object'i ekledi. queryParams: false query param'ların bağlanmasını kapatıyor; yalnız path parametreleri ve route verisi input'a gidiyor. Bu, query param'larla aynı isimli input'u olan component'lerin yanlışlıkla URL'den değer almasını engellemek için. unmatchedInputBehavior ise route verisinde karşılığı olmayan input'ların ne olacağını söylüyor: varsayılan 'alwaysUndefined', eşleşmeyen her input'a undefined yazıyor (eski bir değer ekranda kalmasın diye); 'undefinedIfStale' ise yalnız daha önce router'dan değer almış bir input'a undefined yazıyor, hiç eşleşmemiş input'lara dokunmuyor. Bookstore'un kataloğu varsayılanla çalışıyor ve bu bir avantaj: URL'den ?category= silinince category input'u gerçekten undefined oluyor.
URL Girdidir: transform ile Doğrulama
Query param her zaman string ya da undefined geliyor ve kullanıcı adres çubuğuna istediğini yazabiliyor. Dönüşüm ve doğrulama ayrı bir dosyada, saf fonksiyonlar olarak:
// URL kullanıcı girdisidir: her query param string (ya da undefined) gelir ve içinde her şey olabilir
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);
}
Bu fonksiyonlar input'ların transform seçeneğine veriliyor; input<number, string | undefined> tip parametreleri "dışarıdan string gelir, içeride number kullanılır" demek. Geçersiz bir değer geldiğinde hata vermek yerine varsayılana düşüyoruz: ?page=abc sayfa 1, ?sort=drop-table başlığa göre sıralama. API'ye giden istek her zaman temizlenmiş değerle gidiyor; sunucu da kendi doğrulamasını yapıyor, ama istemcinin bozuk değeri olduğu gibi göndermemesi hem logları hem de cache'i temiz tutuyor.
URL'e Yazmak: router.navigate, queryParamsHandling ve Geri Tuşu
Okuma tarafı input'lar, yazma tarafı router. Component kendi state'ini değiştirmiyor, URL'i değiştiriyor ve geri kalan her şey URL'i izliyor:
// Her değişiklik URL'e yazar; input'lar ve onlar üzerinden resource URL'i izler
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 {
// yazmak kullanıcı için tek adım: her tuşta yeni kayıt eklemek yerine geçmiş kaydını değiştir
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([], ...) aynı route'ta kalıp yalnız query param'ları değiştiriyor. queryParamsHandling: 'merge' verilen parametreleri mevcut olanlarla birleştiriyor; null bir parametreyi URL'den siliyor. Sıralama ya da arama değişince page: null ile sayfayı da sıfırlıyoruz: önceki yazıda linkedSignal'ın yaptığı işi artık bu tek parametre yapıyor. Varsayılan değerleri (sayfa 1, başlığa göre sıralama) URL'e yazmamak da bilinçli: aynı ekranın tek bir adresi olsun. Kategori link'leri de aynı mantıkla yazıldı:
<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 önce paylaşılmış bir link'i doğrudan açtı, sonra adres çubuğuna geçersiz değerler yazdı, son olarak kategoriye ve "Next"e tıklayıp iki kez geri tuşuna bastı. Her satırda URL, özet ve listelenen kitaplar var:
# 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
Paylaşılan link, gönderenin gördüğü ekranı açtı: classics, fiyata göre, 2. sayfa. Geçersiz değerler URL'de kaldı ama isteğe hiç ulaşmadı (page=1&sort=title). Geri tuşu her adımda bir önceki filtreye döndü ve liste URL'i izleyerek yeniden çekildi. Bu davranış için tek satır geçmiş yönetimi kodu yazmadık; router ve tarayıcı geçmişi bu işi zaten yapıyor.
replaceUrl: Her Tuş Bir Geçmiş Kaydı Olmasın
"Önce Tahmin Et"teki ilk sürüm her tuşta normal bir navigation yapıyordu. Kullanıcı classics'e girip "pri" yazdı ve bir kez geri tuşuna bastı:
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
Üç harf geçmişe üç kayıt ekledi (history.length 3'ten 6'ya) ve geri tuşu kullanıcıyı classics'e değil, q=pr'ye götürdü. Aramadan çıkmak için üç kez geri basmak gerekiyor. Asıl koddaki replaceUrl: true ile aynı senaryo:
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
Geçmiş uzunluğu değişmedi, çünkü her tuş mevcut kaydın üstüne yazdı. Burada bir ayrıntı var: ilk tuş da classics kaydının üstüne yazdığı için tek geri tuşu doğrudan ana sayfaya döndü; aramasız classics hâli geçmişte yok. Hangisinin doğru olduğu ürün kararı. Bookstore'da arama "aynı listeyi daraltmak" sayıldığı için bu kabul edildi; aramanın kendi geçmiş adımı olması isteniyorsa ilk tuşta normal navigation, sonrakilerde replaceUrl yapılabilir. Sıralama ve sayfa değişiklikleri ise bilerek normal navigation: kullanıcı geri tuşuyla bir önceki sayfaya dönmeyi bekliyor.
Child Route'lar ve paramsInheritanceStrategy
Kitap detay sayfasına bir "Share" child route'u ekledik: /books/9780441172719/share adresi, paylaşım link'ini gösteren küçük bir panel açıyor. :isbn parent route'a ait:
{
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 parent route'a ait; child onu params inheritance ile alır
readonly isbn = input<string>();
}
Child component isbn'i parent'ın parametresinden alabiliyor mu? Angular 22'de evet:
Dune | Share this book: https://bookstore.example.com/books/9780441172719
Angular 22.0, router'ın paramsInheritanceStrategy varsayılanını 'emptyOnly''den 'always''e çevirdi ve bu changelog'da breaking change olarak yer alıyor. 'emptyOnly''de bir route, parent'ın parametrelerini yalnız kendi path'i boşsa ya da parent'ın component'i yoksa miras alıyordu. Eski davranışı geri açınca:
provideRouter(
routes,
withComponentInputBinding(),
withAutoCleanupInjectors(),
// Angular 22'den önceki davranış
withRouterConfig({ paramsInheritanceStrategy: 'emptyOnly' }),
),
Dune | Share this book: https://bookstore.example.com/books/
Panel boş bir ISBN (kitabın uluslararası standart numarası) ile render edildi: share path'i boş değil ve parent'ın bir component'i var, yani miras yok. Angular 22'den önce yazılmış child component'lerde bu yüzden inject(ActivatedRoute).parent üzerinden parametre okuyan kod sık görülür. Angular 22'ye yükselttikten sonra bu kod çalışmaya devam ediyor, ama yeni kodda gerekmiyor. Ters yönde de dikkat: eskiden miras almayan bir child artık parent'ın parametresiyle aynı isimli bir input'a değer alabilir.
Üretimde Dikkat
URL'deki her değişiklik, onu izleyen resource'lar için yeni bir istek demek. Arama kutusunda her tuş bir istek atıyor; httpResource önceki isteği iptal ettiği için ekranda yanlış sonuç kalmıyor, ama sunucu yine de her tuşu görüyor. Bookstore'un küçük listesi için kabul edilebilir; büyük bir katalogda URL'e yazmadan önce kısa bir bekleme koymak gerekir. RxJS serisinin "Angular 22'de RxJS: HttpClient, Router ve Forms Observable'ları, Signal Interop ve Subscription Yönetimi" yazısındaki debounceTime'lı arama kutusu ya da Angular 22'de experimental olan debounced() bunun iki yolu.
URL'in uzunluğu ve biçimi bir sözleşme. Paylaşılan link'ler e-postalarda, sohbetlerde ve arama motoru indekslerinde yıllarca yaşıyor; bir query param'ın adını değiştirmek (q yerine search) eski link'leri kırar. Parametre adlarını kısa ve kararlı tut, varsayılanları URL'e yazma, gerekiyorsa eski adı bir süre daha okuyup yenisine çevir. Aynı ekranın tek bir adresi olması, analytics'te sayfa görüntülemelerinin dağılmamasına da yarıyor; kanonik adres ve SEO (arama motoru optimizasyonu) tarafı Content SEO serisinin konusu.
Sunucu tarafında da doğrula. İstemcideki transform yalnız kendi uygulamanı koruyor; aynı API'yi doğrudan çağıran biri page=-3 gönderebilir. Sahte API'mizde bu yüzden aynı kural (Math.max(1, ...)) sunucuda da var.
Sık Düşülen Tuzaklar
URL'den gelen string'i doğrulamadan kullanmak
Number(this.route.snapshot.queryParamMap.get('page')) yazıp geçmek, ?page=abc için NaN, ?page= için 0 üretiyor. Bu değer isteğe gider; ekranda "NaN" görünebilir ya da sunucu 400 döner. Doğrulamanın yeri, değerin uygulamaya girdiği tek nokta: input'un transform'u.
Aynı filtreyi iki kaynaktan yönetmek
Filtreyi hem URL'de hem bir store'da tutup birini diğerine effect ile kopyalamak, iki source of truth (verinin asıl kaynağı) üretiyor. Kullanıcı geri tuşuna bastığında URL değişiyor ama store eski değerde; store'u güncelleyen bir buton URL'i değiştirmeyi unutuyor. Kural basit: bir state'in tek sahibi var. Filtre URL'deyse store onu okumaz, URL'den türetilmiş input'u ya da signal'ı okur; değiştirmek isteyen de URL'i değiştirir.
Geçici state'i URL'e yazmak
Kapak dialog'unu ?cover=dune ile URL'e yazmak "paylaşılabilir dialog" gibi görünse de geri tuşunu dialog açıp kapama düğmesine çeviriyor. Bir state'in URL'e girip girmeyeceğini "bu adresi biri bana gönderse bu ekranı görmek ister miyim" sorusuyla test et.
Her tuşta normal navigation
Yukarıdaki çıktıda görüldüğü gibi, her tuş bir geçmiş kaydı ekliyor ve geri tuşu kullanılamaz hâle geliyor. Yazarken replaceUrl: true.
Küçük Bir Deneme
★☆☆ toSearch'e bir kural ekle: 2 karakterden kısa arama metni yok sayılsın. Kutuya "p" yazınca URL'de q=p görünüyor mu? Görünmeli mi?
★★☆ Arama kutusunda ilk tuşta normal navigation, sonraki tuşlarda replaceUrl yap. Bunun için hangi bilgiyi nerede tutman gerekiyor ve o bilgi hangi state türüne giriyor? Playwright ile history.length'i ölçerek doğrula.
★★★ Kataloğa bir "fiyat aralığı" filtresi ekle (?min=200&max=400). min max'tan büyükse ne yapmalı: değerleri yer değiştirmek mi, ikisini de yok saymak mı? İki parametreyi birlikte doğrulayan bir fonksiyon yaz; tek tek transform'lar bunun için neden yetmiyor?
Blok A burada bitiyor: state'in türleri, signal tabanlı service store, server state ve URL state. Bir sonraki yazıda NgRx'e geçiyoruz: ikinci yazıda elle yazdığımız CartStore'u NgRx Signal Store'a çeviriyoruz ve signalStore, withState, withComputed, withMethods, patchState, withHooks ve withProps'un elle yazılmış hâlin hangi parçalarının yerini aldığına bakıyoruz.