Material Veri Tablosu: mat-table, MatSort, MatPaginator, Filtre ve Server-side Sayfalama
Büyüyen bir listeyi MatTableDataSource ile tarayıcıda değil, sort, filtre ve sayfayı URL'de tutup API'de sayfalayarak göster: Bookstore'un 10.000 siparişi tarayıcıda 59 ms'de sıralanıyor ama 1,5 MB'lık cevap 10 Mbit/s'te 1,26 saniye sürüyor. URL'de olmayan param'ın input'a undefined gelmesini, filtre sonrası sayfayı sıfırlamamanın etkisini, linkedSignal ile yüklenirken boş tabloyu önlemeyi ve SelectionModel'i computed() içinde okumanın hatasını ölçüyoruz.
Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (TypeScript 6.0), Angular Material ve CDK (Component Dev Kit; Angular ekibinin stilsiz davranış library'si) 22.2.7, .NET 10 ve Entity Framework Core 10 (EF Core; .NET'in veritabanı erişim library'si) ile denenmiştir; tarayıcı davranışı Playwright 1.65 ile headless Chromium'da gözlenmiştir. Bookstore.Api ölçümlerde SQL Server yerine SQLite ile çalıştı.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) yönetim panelinde siparişler tablosu kuruyoruz: duruma göre filtre, tarihe ve tutara göre sort, sayfa başı 25 satır. Development veritabanında 10.000 sipariş var. Üç soru: Bütün siparişleri bir kerede indirip MatTableDataSource'a versen, kullanıcıyı asıl ne bekletir: 10.000 satırı tarayıcıda sıralamak mı, indirmek mi? Sort ve sayfayı URL'de tutuyoruz; URL'de status yokken input('') ile tanımlanan input'un değeri ne olur? Kullanıcı 300. sayfadayken durumu "Cancelled" yaparsa (1.223 sipariş, 49 sayfa) ne görür?
mat-table'ın Yapısı: Column Definition'lar
mat-table, CDK table'ının Material stilli hâli. Template'te her kolon bir matColumnDef; içinde başlık hücresinin (*matHeaderCellDef) ve veri hücresinin (*matCellDef) template'i duruyor. Hangi kolonların hangi sırayla görüneceğine satır tanımları karar veriyor:
<table mat-table [dataSource]="rows()" matSort [matSortActive]="sort()" [matSortDirection]="dir()"
(matSortChange)="sortChanged($event)">
<ng-container matColumnDef="id" sticky>
<th mat-header-cell *matHeaderCellDef mat-sort-header>Order</th>
<td mat-cell *matCellDef="let order">#{{ order.id }}</td>
</ng-container>
<ng-container matColumnDef="placedAtUtc">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Placed</th>
<td mat-cell *matCellDef="let order">{{ order.placedAtUtc | date: 'dd.MM.yyyy HH:mm' }}</td>
</ng-container>
<ng-container matColumnDef="total">
<th mat-header-cell *matHeaderCellDef mat-sort-header arrowPosition="before">Total</th>
<td mat-cell *matCellDef="let order">{{ order.total | currency: order.currency : 'symbol-narrow' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="columns; sticky: true"></tr>
<tr mat-row *matRowDef="let order; columns: columns"></tr>
<tr class="mat-row" *matNoDataRow>
<td class="mat-cell no-data" [attr.colspan]="columns.length">
@if (orders.error()) {
The orders could not be loaded.
} @else if (orders.isLoading()) {
Loading orders…
} @else {
No orders match this filter.
}
</td>
</tr>
</table>
Kolonun adı aynı zamanda iki şeyin anahtarı: mat-sort-header sort event'inde active olarak bu adı gönderiyor, tablo da kolonun her hücresine mat-column-<ad> class'ını koyuyor. Sabit kolon genişliği için bu class'ı kullanıyoruz; component'in kendi template'indeki element'ler olduğu için encapsulation sorun çıkarmıyor:
// Bir kolonun her hücresine mat-table'ın koyduğu class ile sabit genişlik
.mat-column-select {
width: 48px;
}
.mat-column-id {
width: 96px;
}
.mat-column-total {
width: 120px;
text-align: right;
}

Client-side: MatTableDataSource ve 10.000 Satır
Material'ın dokümanındaki örneklerin çoğu MatTableDataSource kullanıyor: datayı ona verirsin, sort ve paginator'ı bağlarsın, sıralama, filtre ve sayfalama tarayıcıda olur. Tahminin ilk sorusunu ölçmek için bu yolu bilerek kötü örnek olarak kurduk (snapshot'ta yok) ve ölçüm süresince API'nin sayfa sınırını 10.000'e açtık:
// Kötü örnek: 10.000 siparişin hepsi tarayıcıda; sort, filtre ve sayfa MatTableDataSource'ta
export class OrdersClientPage implements AfterViewInit {
protected readonly columns = ['id', 'customerName', 'total'];
protected readonly dataSource = new MatTableDataSource<OrderRow>([]);
private readonly sort = viewChild.required(MatSort);
private readonly paginator = viewChild.required(MatPaginator);
private readonly http = inject(HttpClient);
ngAfterViewInit(): void {
this.dataSource.sort = this.sort();
this.dataSource.paginator = this.paginator();
this.http.get<PagedResult<OrderRow>>('/api/admin/orders', { params: { pageSize: 10000 } })
.subscribe(result => (this.dataSource.data = result.items));
}
}
Production build'de, Chrome DevTools protocol ile ağı 10 Mbit/s ve 40 ms gidiş-dönüş süresine kısıp iki isteği aynı sayfadan attık, sonra 10.000 satırlık tabloda sort ve filtreyi ölçtük:
GET /api/admin/orders?pageSize=10000 1519789 bytes | download 1263 ms | JSON.parse 4 ms
GET /api/admin/orders?page=1&pageSize=25 3860 bytes | download 48 ms | JSON.parse 0 ms
sort Total (10,000 rows) 59 ms | first row: #3672 Alan Hoare TRY219.90
filter "Grace" (10,000 rows) 8 ms | first row: #9226 Grace Hoare TRY219.90
JS heap 17 MB
Cevap: indirmek. Tarayıcı 10.000 satırı 59 ms'de sıralıyor ve 8 ms'de filtreliyor; tablo zaten yalnız 25 satır render ediyor. Pahalı olan 1,5 MB'lık cevap: bu ağda 1,26 saniye, sayfanın ilk ekranından önce. API'de response compression açık değil; açılsa bile byte sayısı kullanıcı sayısıyla değil sipariş sayısıyla büyüyor ve her yeni siparişle artıyor. İkinci sorun tutarlılık: tarayıcıdaki liste indirildiği andaki fotoğraf, beş dakika önce iptal edilen sipariş hâlâ "Paid" görünür. Birkaç yüz satırlık ve yavaş değişen bir liste (Bookstore'un 4 kategorisi gibi) için MatTableDataSource iyi bir seçim; siparişler gibi büyüyen bir tablo için değil.
Server-side: API Tarafı
Sort, filtre ve sayfa veritabanında olsun diye API'ye ayrı bir endpoint ekledik. İki ayrıntı önemli: sayfa boyutu 100 ile sınırlı (istemci pageSize=10000 isteyemesin), her sort da en sonda Id ile bitiyor:
// GET /api/admin/orders?status=Shipped&sort=total&direction=desc&page=2&pageSize=25
page = Math.Max(page, 1);
pageSize = Math.Clamp(pageSize, 1, 100);
IQueryable<Order> query = db.Orders.AsNoTracking();
if (!string.IsNullOrWhiteSpace(status))
{
if (!Enum.TryParse(status, ignoreCase: true, out OrderStatus parsed))
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["status"] = [$"Unknown status '{status}'."]
});
}
query = query.Where(o => o.Status == parsed);
}
bool descending = !string.Equals(direction, "asc", StringComparison.OrdinalIgnoreCase);
// Her sort id ile biter; aynı değerli satırlar sayfa sınırında farklı sıraya bölünmez
query = (sort?.ToLowerInvariant(), descending) switch
{
("id", false) => query.OrderBy(o => o.Id),
("id", true) => query.OrderByDescending(o => o.Id),
("total", false) => query.OrderBy(o => o.Total).ThenBy(o => o.Id),
("total", true) => query.OrderByDescending(o => o.Total).ThenByDescending(o => o.Id),
("customer", false) => query.OrderBy(o => o.Customer.Name).ThenBy(o => o.Id),
("customer", true) => query.OrderByDescending(o => o.Customer.Name).ThenByDescending(o => o.Id),
(_, false) => query.OrderBy(o => o.PlacedAtUtc).ThenBy(o => o.Id),
(_, true) => query.OrderByDescending(o => o.PlacedAtUtc).ThenByDescending(o => o.Id)
};
int total = await query.CountAsync(ct);
List<OrderRow> items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(o => new OrderRow(
o.Id, o.PlacedAtUtc, o.Customer.Name, o.Status.ToString(), o.Items.Count, o.Total, o.Currency))
.ToListAsync(ct);
İkinci kural küçük ama önemli. Demo verideki 10.000 siparişin 9.081'i başka bir siparişle aynı tutarda, en kalabalık grupta 73 sipariş var; yalnız Total'a göre sıralarsan veritabanı eşit satırların sırasını garanti etmez ve bir sipariş hem 1. hem 2. sayfada görünüp bir başkası hiç görünmeyebilir. Demo veriyi Development ortamında "Database:DemoOrders": 10000 ayarıyla, sabit bir Random(2026) seed'iyle üretiyoruz. API'nin sayfası 1'den, MatPaginator'ın pageIndex'i 0'dan başlıyor; dönüşümü tek yerde, component'te yapıyoruz.
URL'deki State: Input'lar ve httpResource
Sort, sayfa ve filtre URL'in query param'ları: sayfa yenilenince ve link paylaşılınca aynı görünüm geliyor, tarayıcının geri tuşu bir önceki sayfaya dönüyor. Bookstore istemcisinde withComponentInputBinding() zaten açık, yani query param'lar component input'larına yazılıyor. İlk denemede input'ları varsayılan değerleriyle tanımladık ve API'ye giden ilk request şuydu:
GET /api/admin/orders?status=undefined&sort=total&direction=asc&page=1&pageSize=25
Tahminin ikinci sorusunun cevabı: undefined. Router, URL'de olmayan bir query param için input'a varsayılan değeri değil undefined yazıyor. API "undefined" diye bir durum bilmediği için 400 döndü ve tablo boş kaldı. Çözüm her input'a bir transform:
// URL state'in kendisi: ?status=Cancelled&sort=total&dir=desc&page=3 (withComponentInputBinding).
// URL'de olmayan query param input'a varsayılan değer olarak değil undefined olarak gelir; transform'lar bu yüzden
readonly status = input('', { transform: (value: string | undefined) => value ?? '' });
readonly sort = input('placedAtUtc', { transform: (value: string | undefined) => value ?? 'placedAtUtc' });
readonly dir = input<'asc' | 'desc', string | undefined>('desc', { transform: value => (value === 'asc' ? 'asc' : 'desc') });
readonly page = input(1, { transform: (value: unknown) => Math.max(1, numberAttribute(value, 1)) });
protected readonly orders = httpResource<PagedResult<OrderRow>>(() => ({
url: '/api/admin/orders',
params: { status: this.status(), sort: this.sort(), direction: this.dir(), page: this.page(), pageSize },
}));
URL'i state olarak kullanmanın ayrıntılarını (replaceUrl, geri tuşu, param doğrulama) Angular'da State Yönetimi serisinin "URL'i State Olarak Kullanmak: Query Param'lar, withComponentInputBinding ve Geri Tuşunun Çalışması" yazısı anlatıyor. Bu tabloda akış şöyle:

Sort, Sayfa ve Filtre Event'leri
Tablo hiçbir state'i kendisi değiştirmiyor; event'ler yalnız URL'i değiştiriyor, gerisi input'lardan akıyor:
protected sortChanged(sort: Sort): void {
this.navigate({ sort: sort.direction ? sort.active : null, dir: sort.direction || null, page: null });
}
protected pageChanged(event: PageEvent): void {
this.navigate({ page: event.pageIndex + 1 });
}
protected statusChanged(status: string): void {
// Yeni bir filtre ilk sayfadan başlar
this.navigate({ status: status || null, page: null });
}
private navigate(queryParams: Record<string, string | number | null>): void {
void this.router.navigate([], { queryParams, queryParamsHandling: 'merge' });
}
<mat-paginator [length]="total()" [pageIndex]="page() - 1" [pageSize]="pageSize" [hidePageSize]="true"
(page)="pageChanged($event)" aria-label="Orders pages" />
Playwright ile tabloyu gezdik; her adımda URL'i, paginator'ın etiketini, ilk satırı ve API'ye giden son request'i yazdırdık:
load url=/admin/orders | 1 – 25 of 10000 | rows=25 | first: #1616 15.11.2026 06:59 Ada Dijkstra Delivered 1 ₺689.00
sort Total url=/admin/orders?sort=total&dir=asc | 1 – 25 of 10000 | rows=25 | first: #482 24.12.2025 18:45 Katherine Johnson Cancelled 1 ₺219.90
GET /api/admin/orders?status=&sort=total&direction=asc&page=1&pageSize=25
next page url=/admin/orders?sort=total&dir=desc&page=2 | 26 – 50 of 10000 | rows=25 | first: #2402 31.10.2026 09:50 Ada Hamilton Shipped 3 ₺3,773.80
GET /api/admin/orders?status=&sort=total&direction=desc&page=2&pageSize=25
page 300 url=/admin/orders?page=300 | 7476 – 7500 of 10000 | rows=25 | first: #6889 14.02.2026 10:52 Katherine Thompson Shipped 3 ₺2,354.80
GET /api/admin/orders?status=&sort=placedAtUtc&direction=desc&page=300&pageSize=25
filter Cancelled url=/admin/orders?status=Cancelled | 1 – 25 of 1223 | rows=25 | first: #6838 15.11.2026 06:56 Donald Hoare Cancelled 3 ₺1,665.90
GET /api/admin/orders?status=Cancelled&sort=placedAtUtc&direction=desc&page=1&pageSize=25
browser back url=/admin/orders?page=300 | 7476 – 7500 of 10000 | rows=25 | first: #6889 14.02.2026 10:52 Katherine Thompson Shipped 3 ₺2,354.80
GET /api/admin/orders?status=&sort=placedAtUtc&direction=desc&page=300&pageSize=25
Tarih kolonu UTC (Coordinated Universal Time; saat dilimlerinin referans aldığı evrensel saat) değerini tarayıcının saat dilimine çevirerek gösteriyor: API'deki 03:59 İstanbul'da 06:59. Sort header'ı üçüncü kez tıklanınca Material sort'u kaldırıyor (direction boş string geliyor); biz o zaman query param'ları silip varsayılan sıralamaya dönüyoruz. Tahminin üçüncü sorusu için statusChanged'deki page: null'ı geçici olarak kaldırdık:
filter Cancelled url=/admin/orders?page=300&status=Cancelled | 7476 – 7500 of 1223 | No orders match this filter.
Kullanıcı 49 sayfalık bir listenin 300. sayfasında, "1223 kaydın 7476–7500 arası" gibi anlamsız bir etiketle boş bir tablo görüyor. Sort'ta da aynı kural geçerli: sıralama değişince 7. sayfada kalmak, kullanıcının hiç görmediği bir dilimi göstermek demek.
Yüklenirken ve Boşken
httpResource yeni parametrelerle yüklenirken value() o istek bitene kadar boş. Tabloyu doğrudan ona bağlarsan her sayfa geçişinde tablo bir an "No orders" satırına düşüyor ve paginator "0 of 0"a dönüyor. Bir önceki sayfayı ekranda tutmak için satırları ve toplamı linkedSignal ile türettik:
// Sonraki sayfa yüklenirken boş tablo yerine bir önceki sayfa ekranda kalır
protected readonly rows = linkedSignal<PagedResult<OrderRow> | undefined, OrderRow[]>({
source: () => this.orders.value(),
computation: (result, previous) => result?.items ?? previous?.value ?? [],
});
API'yi Playwright'ta 800 ms geciktirip sonraki sayfaya geçtik; önce satırları doğrudan orders.value()'dan türeterek, sonra linkedSignal ile:
# rows = orders.value()?.items ?? []
while loading page 2 : rows 1 | Loading orders… | 0 of 0
# linkedSignal ile bir önceki değer korunuyor
while loading page 2 : rows 25 | progress bar 1 | first #1616
page 2 loaded : first #606
Yükleme sırasında eski 25 satır ve üstte bir mat-progress-bar görünüyor, cevap gelince satırlar değişiyor. *matNoDataRow ise tablo gerçekten boşken tek bir satır gösteriyor; hata, yükleniyor ve filtre sonucu boş durumlarını aynı satırda ayırıyoruz.
Satır Seçimi ve Sticky Kolonlar
Toplu işlem için satırları CDK'nın SelectionModel'i ile seçiyoruz. Her cevapta satırlar yeni object'ler olduğu için seçimde object değil sipariş id'si tutuyoruz; böylece seçim sayfa ve sort değişince korunuyor:
// Seçilen sipariş id'leri sayfa ve sort değişince korunur; satırlar her cevapta yeni object'tir
protected readonly selection = new SelectionModel<number>(true);
// computed() değil method: SelectionModel bir signal değil, computed() ilk cevabını saklardı
protected allOnPageSelected(): boolean {
const ids = this.rows().map(row => row.id);
return ids.length > 0 && ids.every(id => this.selection.isSelected(id));
}
Yorum satırı gerçek bir hatadan geliyor. allOnPageSelected'i önce computed() olarak yazdık; computed yalnız okuduğu signal'ler değişince yeniden hesaplanır ve SelectionModel signal değil. Başlıktaki checkbox ile ölçtük:
# computed() ile
click header checkbox 25 selected | header checked: true
click header again 25 selected | header checked: false
# method ile
click header checkbox 25 selected | header checked: true
click header again 0 selected | header checked: false
İlk sürümde ikinci tıklama seçimi kaldırmak yerine yine hepsini seçti; sayfadaki seçimi temizlemenin yolu kalmadı. Sayfa geçişi ve sticky başlık ölçümleri:
page 1 selected : 2 selected
back on page 1 : 2 selected | checked 2
sticky header : top before 208 after scrolling 400px 208 | first row cell -140
*matHeaderRowDef="columns; sticky: true" başlık hücrelerine, matColumnDef="id" sticky kolonun hücrelerine position: sticky veriyor. Sticky'nin çalışması için tablonun kendi scroll alanı olmalı; tabloyu max-height ve overflow: auto olan bir kapsayıcıya koyduk. Satır 400px kaydı, başlık yerinde kaldı.
Üretimde Dikkat
Sort kolonlarını index'le. 10.000 satırda SQLite bile hızlı, ama sipariş tablosu büyüdükçe ORDER BY PlacedAtUtc ve WHERE Status index ister; SQL Server'da (Status, PlacedAtUtc) gibi bir index ve sık kullanılan sort'lar için ayrı index'ler düşün. Bunu bu yazıda ölçmedik; EF Core serisi ve SQL serileri index seçimini ayrıntılı ele alıyor.
Derin sayfalar pahalı. Skip(7475) veritabanına 7.475 satırı okuyup atlamasını söylüyor. Panelde kimse 300. sayfaya elle gitmez, ama dışa aktarma gibi bütün listeyi gezen işler için sayfa numarası yerine "şu id'den sonraki 25" diyen keyset pagination daha uygun.
COUNT da bir sorgu. Her sayfa isteğinde toplam sayıyı yeniden hesaplıyoruz; çok büyük tablolarda toplamı cache'lemek ya da "1000+" demek bir seçenek.
Sayfa boyutunu sunucu sınırlasın. İstemciden gelen pageSize'a güvenme; bizim API'miz 100'de kesiyor.
Testleri URL sözleşmesine yaz. Bu yazıdaki beş test, input'lardan API query'sine dönüşümü (undefined dahil), paginator etiketini, filtre ve sort'un sayfayı sıfırlamasını ve başlık checkbox'ını kontrol ediyor; computed() sürümünü geri koyunca sonuncusu expected '3 selected' to be '0 selected' ile kırıldı.
✓ src/app/admin/orders/orders-page.spec.ts > OrdersPage > turns the URL state into the API query, also when query params are missing
✓ src/app/admin/orders/orders-page.spec.ts > OrdersPage > shows the server total in the paginator and keeps a zero-based page index
✓ src/app/admin/orders/orders-page.spec.ts > OrdersPage > starts from the first page when the status filter changes
✓ src/app/admin/orders/orders-page.spec.ts > OrdersPage > writes the sort into the URL and resets the page
✓ src/app/admin/orders/orders-page.spec.ts > OrdersPage > selects and deselects every order on the page from the header checkbox
Tests 13 passed (13)
Sık Düşülen Tuzaklar
Birincisi, 10.000 satırı MatTableDataSource'a verip tarayıcıda sayfalamak. Sıralama hızlı, indirme yavaş ve liste her gün büyüyor.
İkincisi, filtre ya da sort değişince sayfayı sıfırlamamak. Paginator "1223 kaydın 7476–7500 arası" gibi bir etiket gösterir.
Üçüncüsü, withComponentInputBinding'de URL'de olmayan param'ın varsayılan değere düşeceğini sanmak. undefined gelir.
Dördüncüsü, sort'u tek kolonla bitirmek. Eşit değerli satırlar sayfa sınırında kaybolur ya da iki kez görünür.
Beşincisi, signal olmayan bir state'i (SelectionModel) computed() içinde okumak.
Küçük Bir Deneme
★☆☆ Paginator'a [pageSizeOptions]="[25, 50, 100]" ekle, sayfa boyutunu da URL'e size olarak yaz ve sayfa boyutu değişince hangi sayfaya dönmen gerektiğine karar ver.
★★☆ Müşteri adına göre arama kutusu ekle. Her tuşta URL değişmesin diye debounce'u nerede yaparsın: input'ta mı, URL'e yazmadan önce mi? Tarayıcı geçmişini kirletmemek için replaceUrl'i dene.
★★★ API'ye afterId parametresiyle keyset pagination ekle ve PlacedAtUtc DESC, Id DESC sırasında "sonraki 25"i getir. Paginator'ın hangi özelliklerinden vazgeçmen gerekiyor (toplam sayfa, son sayfaya atlama)?
Tablo seçili siparişleri biliyor ama henüz onlarla bir şey yapmıyor. Sonraki yazıda tabloya "Siparişi iptal et" onay dialog'unu, sepetten silince "Geri al" diyen snackbar'ı ve mobil için bir bottom sheet'i ekliyoruz; MatDialog'a veri vermeyi, afterClosed ile sonucu almayı, CDK Dialog'u ve native dialog ile farkı ölçüyoruz.