Razor Component Temelleri: Parametreler, Event Handling, Data Binding ve Component Lifecycle
Razor component dışarıdan [Parameter] ile değer alır, parent'a EventCallback ile haber verir, @bind ile input'a bağlanır ve ComponentBase'in lifecycle method'ları belli bir sırayla çalışır; prerendering açıksa OnInitializedAsync iki kez çağrılır. Bookstore yönetim panelinin stok ekranını BookList, BookRow ve LowStockBadge ile kurup @bind:after, @bind:get/set, async handler'da render ve InvokeAsync gerektiren zamanlayıcıyı gerçek log'larla ve Angular karşılıklarıyla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15) ile derlenip denenmiştir; tarayıcı davranışı Playwright 1.65'in headless Chromium'uyla gözlenmiştir.
Önce Tahmin Et
Bu yazıda panelin stok ekranını yazıyoruz. Sayfa component'i @rendermode InteractiveServer ile interaktif ve veriyi OnInitializedAsync içinde yüklüyor; method her çalıştığında log'a bir satır yazıyor. Sayfayı tarayıcıda bir kez açtığında bu satırı log'da kaç kez görürsün? İkinci soru: eşik değeri girilen kutu @bind:event="oninput" ile bağlı. Kutuya "12" yazdığında, eşiği parametre olarak alan alt component'in OnParametersSet'i kaç kez çalışır? Üçüncüsü: zamanlayıcıyla 30 saniyede bir veri yenileyen kodun sonunda StateHasChanged()'i doğrudan çağırırsan ne olur? Cevaplarını not et; hepsini log'larla göreceğiz.
Stok Ekranı İçin Veri
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) API'sinde stok bilgisi yalnız tek kitap detayında vardı. Panelin bütün kitapları stoklarıyla birlikte listeleyebilmesi ve depo sayımından sonra stoğu güncelleyebilmesi için Bookstore.Api'ye iki back office endpoint'i ekledim:
public sealed record StockView(string Isbn, string Title, int OnHand, int Reserved, int Available);
public sealed record SetStockRequest([property: Range(0, 10_000)] int QuantityOnHand);
// Bookstore.Admin'in kullandığı back office endpoint'leri. Authorization Blazor serisinde daha sonra geliyor.
public static class InventoryEndpoints
{
public static IEndpointRouteBuilder MapInventoryEndpoints(this IEndpointRouteBuilder app)
{
RouteGroupBuilder inventory = app.MapGroup("/api/inventory").WithTags("Inventory");
inventory.MapGet("/", GetStock);
inventory.MapPut("/{isbn}", SetQuantityOnHand);
return app;
}
GET /api/inventory kitapları en az stoklu olandan başlayarak döndürüyor, PUT /api/inventory/{isbn} eldeki adedi güncelliyor ve rezerve edilmiş adedin altına inmeye izin vermiyor. Endpoint'lerin integration testleri InventoryTests class'ında. Bu endpoint'ler şimdilik herkese açık; panelin authorization'ı 11. yazının konusu. Panel tarafında BookstoreApiClient'a GetStockAsync ve SetQuantityOnHandAsync method'ları, .Client'taki contract'lara da aynı adlı StockView record'u eklendi.
Parametreler ve EventCallback
Ekranı üç component'e bölüyorum: tabloyu çizen BookList, her satırı çizen BookRow ve stok azaldığında görünen LowStockBadge. En küçüğünden başlayalım:
@if (Available == 0)
{
<span class="badge bg-danger">Out of stock</span>
}
else if (Available <= Threshold)
{
<span class="badge bg-warning text-dark">Low: @Available left</span>
}
@code {
[Parameter, EditorRequired]
public int Available { get; set; }
[Parameter]
public int Threshold { get; set; } = 5;
}
Bir component'in dışarıdan aldığı değerler [Parameter] ile işaretli public property'ler. Kullanırken HTML attribute'u gibi yazılıyor: <LowStockBadge Available="3" Threshold="5" />. Angular'daki @Input() ya da input() ile aynı rol. [EditorRequired] parametrenin zorunlu olduğunu söylüyor; zorunlu bir parametreyi vermeden component'i kullanırsan Razor derleyicisi RZ2012 uyarısı üretiyor. Bookstore'da TreatWarningsAsErrors açık olduğu için bu uyarı build'i kırıyor:
src/Bookstore.Admin/Components/Inventory/BookList.razor(10,13): error RZ2012: Component 'BookRow' expects a value for the parameter 'Item', but a value may not have been provided.
Bu kontrol yalnız derleme anında, markup'ta attribute'un yazılıp yazılmadığına bakıyor; çalışma anında null gelmesini engellemiyor. Satır component'i parent'ına bir şey bildirmek zorunda: "bu kitabın stoğunu 10 artır". Bunun için EventCallback<T> kullanıyorum:
namespace Bookstore.Admin.Components.Inventory;
// Kullanıcı bir kitabın stoğunu artırdığında BookRow'un parent'ına ilettiği bilgi.
public sealed record RestockRequest(string Isbn, int Amount);
<tr>
<td>@Item.Title <LowStockBadge Available="Item.Available" Threshold="LowStockThreshold" /></td>
<td class="text-end">@Item.OnHand</td>
<td class="text-end">@Item.Reserved</td>
<td class="text-end">@Item.Available</td>
<td>
<input type="number" min="1" max="500" class="form-control form-control-sm d-inline w-auto" @bind="amount" />
<button class="btn btn-sm btn-primary" disabled="@saving" @onclick="RestockClicked">
@(saving ? "Saving..." : "Restock")
</button>
</td>
</tr>
@code {
private int amount = 10;
private bool saving;
[Parameter, EditorRequired]
public StockView Item { get; set; } = null!;
[Parameter]
public int LowStockThreshold { get; set; }
[Parameter]
public EventCallback<RestockRequest> OnRestock { get; set; }
private async Task RestockClicked()
{
saving = true;
await OnRestock.InvokeAsync(new RestockRequest(Item.Isbn, amount));
saving = false;
}
}
EventCallback<RestockRequest> Angular'daki @Output() / output()'un karşılığı. Neden düz bir Func<RestockRequest, Task> değil? İki fark var. Birincisi, EventCallback çağrı bittiğinde callback'in sahibi olan component'te, yani parent'ta StateHasChanged'i kendisi çağırır; parent'ın yeniden render olması için ek kod gerekmez. İkincisi, struct olduğu ve parent hiçbir şey bağlamadığında InvokeAsync sessizce tamamlandığı için null kontrolüne gerek yok. @onclick="RestockClicked" ise DOM (Document Object Model; tarayıcının sayfayı tuttuğu ağaç yapısı) event'ine bir C# method'u bağlıyor; method parametresiz olabilir ya da MouseEventArgs gibi event argümanını alabilir, lambda da yazılabilir (@onclick="() => amount++").
@inject ILogger<BookList> Logger
<table class="table table-sm align-middle">
<thead>
<tr><th>Title</th><th class="text-end">On hand</th><th class="text-end">Reserved</th><th class="text-end">Available</th><th></th></tr>
</thead>
<tbody>
@foreach (StockView item in Items)
{
<BookRow Item="item" LowStockThreshold="LowStockThreshold" OnRestock="OnRestock" />
}
</tbody>
</table>
<p>@Items.Count(i => i.Available <= LowStockThreshold) of @Items.Count books are at or below the threshold.</p>
@code {
[Parameter, EditorRequired]
public IReadOnlyList<StockView> Items { get; set; } = [];
[Parameter]
public int LowStockThreshold { get; set; }
[Parameter]
public EventCallback<RestockRequest> OnRestock { get; set; }
public override Task SetParametersAsync(ParameterView parameters)
{
if (parameters.TryGetValue(nameof(LowStockThreshold), out int next) && next != LowStockThreshold)
{
Logger.LogInformation("SetParametersAsync: threshold {Old} -> {New}", LowStockThreshold, next);
}
return base.SetParametersAsync(parameters);
}
protected override void OnParametersSet() =>
Logger.LogInformation("OnParametersSet: {Count} items, threshold {Threshold}", Items.Count, LowStockThreshold);
}
BookList, aldığı OnRestock'u olduğu gibi satırlara geçiriyor; event satırdan sayfaya kadar iki seviye çıkıyor. SetParametersAsync'i yalnız log için override ettim: ParameterView yeni değerleri henüz property'lere yazılmadan gösterir ve base.SetParametersAsync çağrılınca lifecycle normal akışına devam eder. Bu override'ı gerçek kodda nadiren yazarsın; burada amaç neyin ne zaman çalıştığını görmek.
Event Handling ve Data Binding
Sayfanın kendisi:
@page "/inventory"
@rendermode InteractiveServer
@implements IDisposable
@inject BookstoreApiClient Api
@inject ILogger<InventoryPage> Logger
<PageTitle>Inventory</PageTitle>
<h1>Inventory</h1>
<p>
<label>Low-stock threshold
<input type="number" class="form-control form-control-sm d-inline w-auto"
@bind:get="threshold" @bind:set="SetThreshold" @bind:event="oninput" />
</label>
<label class="ms-3">
<input type="checkbox" @bind="onlyLow" @bind:after="LoadAsync" /> Only low stock
</label>
</p>
@if (stock is null)
{
<p>Loading...</p>
}
else
{
<BookList Items="stock" LowStockThreshold="threshold" OnRestock="RestockAsync" />
<p class="text-muted">Refreshed at @refreshedAt.ToString("HH:mm:ss") UTC</p>
}
@code {
private IReadOnlyList<StockView>? stock;
private int threshold = 5;
private bool onlyLow;
private DateTime refreshedAt;
private System.Timers.Timer? timer;
protected override async Task OnInitializedAsync()
{
Logger.LogInformation("OnInitializedAsync (renderer: {Renderer}, interactive: {Interactive})",
RendererInfo.Name, RendererInfo.IsInteractive);
await LoadAsync();
}
protected override void OnAfterRender(bool firstRender)
{
if (!firstRender)
{
return;
}
Logger.LogInformation("OnAfterRender(firstRender: true): starting the refresh timer");
timer = new System.Timers.Timer(TimeSpan.FromSeconds(30));
timer.Elapsed += OnTimerElapsed;
timer.Start();
}
private async Task LoadAsync()
{
IReadOnlyList<StockView> all = await Api.GetStockAsync();
stock = onlyLow ? [.. all.Where(s => s.Available <= threshold)] : all;
refreshedAt = DateTime.UtcNow;
}
private void SetThreshold(int value) => threshold = Math.Clamp(value, 0, 50);
private async Task RestockAsync(RestockRequest request)
{
StockView current = stock!.Single(s => s.Isbn == request.Isbn);
await Api.SetQuantityOnHandAsync(request.Isbn, current.OnHand + request.Amount);
await LoadAsync();
Logger.LogInformation("Restocked {Isbn} by {Amount}", request.Isbn, request.Amount);
}
// Blazor'un event akışının dışında, thread pool'daki bir thread'de çalışır: InvokeAsync ile renderer'a geç.
private void OnTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e) =>
_ = InvokeAsync(async () =>
{
await LoadAsync();
StateHasChanged();
Logger.LogInformation("Timer refresh rendered");
});
public void Dispose()
{
Logger.LogInformation("Dispose: stopping the refresh timer");
timer?.Dispose();
}
}
Üç farklı binding var. Satırdaki adet kutusu en basit hâli: @bind="amount" input'un value'sunu field'a bağlar ve varsayılan olarak onchange'de, yani kutudan çıkınca ya da Enter'a basınca günceller. Angular'daki [(ngModel)]'ın karşılığı. Checkbox'ta @bind:after="LoadAsync" var: önce onlyLow güncellenir, sonra LoadAsync çağrılır ve dönen Task beklenir. Eskiden bunun için @bind'ı bırakıp value ve @onchange'i elle yazmak gerekirdi.
Eşik kutusu @bind:get ve @bind:set kullanıyor: değer threshold'dan okunur ama kullanıcının yazdığı değer field'a doğrudan değil, SetThreshold üzerinden yazılır ve 0 ile 50 arasına sıkıştırılır. @bind:event="oninput" güncellemeyi her tuş vuruşunda yapar. Playwright ile kutuya önce "12", sonra tek seferde "80" yazdığımda:
[step] loaded: 2 of 20 books are at or below the threshold.
[step] threshold typed 12: 2 of 20 books are at or below the threshold.
[step] threshold filled 80, input now shows 50
80 yazılan kutu 50 gösteriyor: setter değeri sıkıştırdı, Blazor da render sırasında input'un değerini field'ın gerçek değerine geri yazdı. Bu "binding'i bir method'dan geçirme" özelliği Angular'da signal'larla ya da ngModelChange'le elle kurduğun şeyin Blazor'daki hazır hâli.
Lifecycle'ı Log'dan Okumak

ComponentBase ilk render'da SetParametersAsync, OnInitialized{Async}, OnParametersSet{Async}, render ve OnAfterRender{Async}(firstRender: true) sırasını izler. Senkron ve asenkron sürümü olan method'larda önce senkron olan çağrılır. Async bir method tamamlanmamış bir Task dönerse component o anki state ile bir kez render edilir, Task bitince bir kez daha; "Loading..." mesajını bu yüzden gösterebiliyoruz. Sayfayı tarayıcıda bir kez açtığımda log'un başı şöyle:
info: Bookstore.Admin.Components.Pages.Inventory.InventoryPage[0]
OnInitializedAsync (renderer: Static, interactive: False)
info: Bookstore.Admin.Components.Inventory.BookList[0]
SetParametersAsync: threshold 0 -> 5
info: Bookstore.Admin.Components.Inventory.BookList[0]
OnParametersSet: 20 items, threshold 5
info: Bookstore.Admin.Components.Pages.Inventory.InventoryPage[0]
Dispose: stopping the refresh timer
info: Bookstore.Admin.Components.Pages.Inventory.InventoryPage[0]
OnInitializedAsync (renderer: Server, interactive: True)
info: Bookstore.Admin.Components.Inventory.BookList[0]
SetParametersAsync: threshold 0 -> 5
info: Bookstore.Admin.Components.Inventory.BookList[0]
OnParametersSet: 20 items, threshold 5
info: Bookstore.Admin.Components.Pages.Inventory.InventoryPage[0]
OnAfterRender(firstRender: true): starting the refresh timer
Baştaki ilk sorunun cevabı: iki kez. İlk dört kayıt prerender'a ait. Sunucu sayfayı Static SSR (sayfanın sunucuda düz HTML olarak üretilmesi) gibi bir kez render edip HTML'i gönderdi; bu instance'ta RendererInfo.Name Static, OnAfterRender hiç çağrılmadı ve yanıt bitince component dispose edildi. Zamanlayıcı da bu yüzden OnAfterRender'da başlıyor; prerender instance'ında hiç başlamıyor, dispose edilecek bir şey de yok. Sonra tarayıcıda circuit kuruldu ve sayfa sıfırdan, bu kez interaktif olarak oluşturuldu: ikinci OnInitializedAsync, API'ye ikinci istek. Kullanıcı bunu fark etmez ama API iki istek görür ve arada veri değiştiyse ekran bir an değişir. Bu çift yüklemeyi 06'da [PersistentState] ile çözeceğiz.
Angular'dan gelenler için eşleme:
| Blazor | Angular | Not |
|---|---|---|
[Parameter] | @Input() / input() | Değer parent render olduğunda gelir |
EventCallback<T> | @Output() / output() | Parent'ı kendisi yeniden render eder |
@bind, @bind:get/@bind:set | [(ngModel)], model() | @bind:event ile tetikleyen event seçilir |
OnInitialized{Async} | ngOnInit | Prerender varsa iki kez çalışır |
OnParametersSet{Async} | ngOnChanges | Değişen parametre listesi gelmez |
OnAfterRender{Async}(firstRender) | ngAfterViewInit, afterNextRender | Prerender'da çağrılmaz; JS interop burada |
Dispose / DisposeAsync | ngOnDestroy, DestroyRef | @implements IDisposable ile |
ShouldRender() | OnPush change detection | 08'de |
Angular serisinin "Angular Lifecycle Hook'ları" yazısını okuduysan en büyük fark şu: Blazor'da change detection yok. Component, bir event handler'dan, bir lifecycle method'undan ya da StateHasChanged'den sonra render edilir; framework senin field'larını izlemez.
Parametre Değişimi
Eşik kutusuna "12" yazarken log'a düşenler:
info: Bookstore.Admin.Components.Inventory.BookList[0]
SetParametersAsync: threshold 5 -> 1
info: Bookstore.Admin.Components.Inventory.BookList[0]
OnParametersSet: 20 items, threshold 1
info: Bookstore.Admin.Components.Inventory.BookList[0]
SetParametersAsync: threshold 1 -> 12
info: Bookstore.Admin.Components.Inventory.BookList[0]
OnParametersSet: 20 items, threshold 12
info: Bookstore.Admin.Components.Inventory.BookList[0]
SetParametersAsync: threshold 12 -> 50
info: Bookstore.Admin.Components.Inventory.BookList[0]
OnParametersSet: 20 items, threshold 50
İkinci sorunun cevabı: iki kez, her tuş vuruşunda bir. "1" yazıldığında eşik 1 oldu, "2" eklenince 12. Interactive Server'da her biri tarayıcıdan sunucuya giden bir mesaj, bir render ve geri gelen bir DOM diff'i. Eşik gibi ucuz bir hesap için sorun değil; ama oninput'a bağlı bir alan her tuşta API'ye gidiyorsa bunu fark etmek gerekiyor. Bir de son satıra dikkat: BookList'in OnParametersSet'i eşik değişmediği hâlde de çalışabiliyor. Items bir IReadOnlyList<StockView>; Blazor böyle karmaşık tipteki bir parametrenin değişip değişmediğini bilemez ve parent her render olduğunda parametreleri yeniden set eder. Child'ın bütün parametreleri string, sayı ya da enum gibi Blazor'un tanıdığı basit tiplerdeyse ve hiçbiri değişmediyse child yeniden render edilmez; LowStockBadge bu durumda, yalnız iki int alıyor.
StateHasChanged Ne Zaman Gerekir?
Çoğu zaman gerekmez. Event handler'dan ve lifecycle method'larından sonra ComponentBase render'ı kendisi tetikler. "Restock"a tıkladığımda Playwright'ın bir MutationObserver ile kaydettiği buton hâlleri ve log:
[step] War and Peace before: War and Peace Low: 3 left 3 0 3 Restock
[step] button states seen: Saving... (disabled) -> Restock
[step] War and Peace after: War and Peace Low: 13 left 13 0 13 Restock
info: Bookstore.Admin.Components.Inventory.BookList[0]
OnParametersSet: 20 items, threshold 50
info: Bookstore.Admin.Components.Pages.Inventory.InventoryPage[0]
Restocked 9781400079988 by 10
info: Bookstore.Admin.Components.Inventory.BookList[0]
OnParametersSet: 20 items, threshold 50
info: Bookstore.Admin.Components.Inventory.BookList[0]
OnParametersSet: 20 items, threshold 50
RestockClicked saving = true yapıp parent'ın işini beklemeye başladığı anda Task henüz bitmemişti; Blazor satırı o hâliyle render etti ve buton "Saving..." ile devre dışı göründü. İş bitince handler tamamlandı ve bir render daha geldi. Arada parent da render oldu: EventCallback sayfanın RestockAsync'ini çağırdı, o da ilk await'te bir render, bitince bir render daha üretti. Hiçbir yerde StateHasChanged yazmadık.
Gerektiği yer, Blazor'un haberi olmadan çalışan kod: zamanlayıcı, bir C# event'i, arka planda biten bir iş. Sayfadaki System.Timers.Timer'ın Elapsed event'i thread pool'daki bir thread'de çalışıyor. Önce handler'ı InvokeAsync kullanmadan yazdım: refreshedAt'i güncelleyip StateHasChanged()'i doğrudan çağıran, hatayı da log'a yazan bir sürüm. Log:
fail: Bookstore.Admin.Components.Pages.Inventory.InventoryPage[0]
Timer refresh failed
System.InvalidOperationException: The current thread is not associated with the Dispatcher. Use InvokeAsync() to switch execution to the Dispatcher when triggering rendering or component state.
at Microsoft.AspNetCore.Components.ComponentBase.StateHasChanged()
Üçüncü sorunun cevabı bu exception. Interactive Server'da her circuit'in kendi dispatcher'ı var ve component state'i yalnız onun üzerinden değiştirilmeli; WinForms ya da WPF (Windows Presentation Foundation; Windows masaüstü UI framework'ü) uygulamalarındaki UI thread'ine geri dönme kuralının aynısı. Kodun son hâlinde handler işin tamamını InvokeAsync'e veriyor: veri yükleme ve StateHasChanged dispatcher üzerinde çalışıyor. Sayfa açıkken 30 saniye beklediğimde ve sonra başka bir sayfaya geçtiğimde:
[step] timer: "Refreshed at 02:51:17 UTC" -> "Refreshed at 02:51:42 UTC"
[step] navigated to /books
info: Bookstore.Admin.Components.Pages.Inventory.InventoryPage[0]
Timer refresh rendered
info: Bookstore.Admin.Components.Pages.Inventory.InventoryPage[0]
Dispose: stopping the refresh timer
Sayfadan çıkınca router sayfayı ağaçtan kaldırdı ve Dispose çağrıldı; zamanlayıcı durdu. Dispose olmasaydı zamanlayıcı ölü bir component'e veri yüklemeye devam eder, circuit kapanana kadar da bellekte kalırdı. C# Asenkron serisinin SynchronizationContext yazısında anlatılan mekanizma burada iş başında: Blazor Server'ın renderer'ı kendi senkronizasyon context'ini kullanıyor ve InvokeAsync işi o context'e taşıyor.
Üretimde Dikkat
Interactive Server'da her render bir ağ mesajı demek. Log'da gördüğün her OnParametersSet, sunucuda yapılan bir diff hesabı ve tarayıcıya giden bir paket. 20 satırlık bir tabloda sorun değil; 2.000 satırlık bir tablo ve oninput'a bağlı bir filtre aynı kodla yavaşlar. Büyük listelerde render maliyetini düşürmek (@key, ShouldRender, Virtualize) 08. yazının konusu; şimdilik log'daki render sayısını bir metrik gibi takip et.
OnInitializedAsync içinde yapılan her şey prerender yüzünden iki kez yapılıyor. Sadece okuma yapan bir API çağrısı için bedeli ekstra bir istek; ama burada bir yazma işlemi (log tablosuna kayıt, sayaç artırma, e-posta gönderme) olursa iki kez yazılır. Yan etkisi olan işleri lifecycle method'larına koyma; kullanıcının bir eylemine bağla.
Zamanlayıcı, event aboneliği ya da CancellationTokenSource gibi kaynak tutan her component IDisposable ya da IAsyncDisposable uygulamalı. Interactive Server'da dispose edilmeyen bir abonelik, circuit yaşadığı sürece, yani kullanıcı sekmeyi kapatana kadar bellekte kalır. Kodumuzdaki _ = InvokeAsync(...) satırı bir fire-and-forget (sonucu beklenmeyen çağrı): içinde exception olursa kimse görmez. Production kodunda bu bloğu bir try/catch ile sarıp hatayı log'a yazmak gerekiyor.
Sık Düşülen Tuzaklar
Child component'in parametresini component'in içinden değiştirmek. BookRow içinde Item = Item with { OnHand = 99 } yazarsan değer bir sonraki parent render'ında ezilir; Blazor dokümanında bu durum için ayrı bir sayfa var ("Avoid overwriting parameters"). Parametre parent'ın malıdır; değiştirmek istiyorsan EventCallback ile parent'a bildir ya da component'in içinde ayrı bir field kullan, satırdaki amount gibi.
Async void event handler. @onclick'e async void bir method bağlarsan Blazor Task'ı göremez: handler bitince ikinci render gelmez ve exception'lar yakalanmaz. Handler'lar her zaman Task döner.
OnAfterRender'da state değiştirip StateHasChanged çağırmak. Bu yeni bir render, o da yeni bir OnAfterRender demek; firstRender kontrolü olmadan sonsuz döngü kurarsın.
Prerender'da çalışmayacak kodu OnInitializedAsync'e koymak. JavaScript çağrısı, tarayıcı storage'ı ya da "kullanıcının ekranı" varsayan her şey prerender sırasında çalışamaz. Bunların yeri OnAfterRenderAsync(firstRender: true); ayrıntısı 10. yazıda.
Küçük Bir Deneme
★☆☆ LowStockBadge'e bir [Parameter] public string? Label { get; set; } ekle ve BookRow'da yalnız tükenen kitaplar için "Order now" yazdır. Parametreyi [EditorRequired] yaparsan build'e ne olur?
★★☆ Eşik kutusunu @bind:event="oninput" yerine varsayılan onchange ile bağla ve aynı "12" denemesini yap. BookList log'unda kaç satır görüyorsun? Kullanıcı için ne değişti?
★★★ InventoryPage'deki System.Timers.Timer'ı bir PeriodicTimer ve CancellationTokenSource ile yeniden yaz; döngüyü OnAfterRenderAsync'te başlat, DisposeAsync'te iptal et. Bu sürümde InvokeAsync'e hâlâ ihtiyacın var mı? Döngüyü Task.Run içinde başlatınca cevap değişiyor mu? (İpucu: C# Asenkron serisindeki SynchronizationContext yazısı.)
Stok ekranı interaktif ama render mode'u sayfanın tamamına @rendermode InteractiveServer ile verdik ve neden öyle yaptığımızı tartışmadık. Bir sonraki yazıda render mode'lara ayrıntılı bakıyoruz: mode'u nerede tanımlamak gerekir, alt component'lere nasıl yayılır, prerendering nasıl kapatılır ve hangi kod nerede çalışır; örnek olarak katalog sayfasını Static SSR'da bırakıp tarayıcıda çalışan bir sepet önizlemesi ekliyoruz.