Zaawansowane tabele danych z TanStack Table w Next.js
Jak zbudować w Next.js tabelę z TanStack Table v9: type-safe kolumny, sortowanie, filtry, paginację, selekcję oraz tryb serwerowy ze stanem w URL.
Maciej Sala
Founder StriveLab
5 min czytaniaAktualizacja Opublikowano 11 kwietnia 2026 (Aktualizacja 1 września 2026)
Instalacja TanStack Table v9
Code
npm install @tanstack/react-table
Ten artykuł używa stabilnego API TanStack Table v9. Kod z v8 oparty na useReactTable() i getPaginationRowModel() wymaga migracji albo świadomego użycia warstwy legacy. W v9 podstawą są useTable(), tableFeatures() oraz fabryki modeli takie jak createPaginatedRowModel().
Model danych i type-safe kolumny
Zacznij od DTO niezależnego od modelu ORM. Kwotę przechowujemy jako liczbę całkowitą w groszach, dzięki czemu kod klienta nie wykonuje działań na liczbach zmiennoprzecinkowych. Datę przekazujemy jako ISO string, ponieważ propsy przekraczające granicę Server/Client Component muszą być serializowalne.
Konfigurację funkcji i kolumn trzymaj po stronie klienta. createColumnHelper zna typ wartości każdego accessora, dlatego info.getValue() dla totalMinor ma typ number, a dla status właściwą unię stringów.
Accessor klienta łączy nazwę i e-mail, więc globalny filtr faktycznie przeszukuje obie wartości. Formatery i mapa statusów powstają raz na poziomie modułu. Jawna strefa czasowa usuwa zależność wyniku od konfiguracji serwera i przeglądarki.
Tabela klientowa: sortowanie, filtry i paginacja
Wariant klientowy ma sens, gdy przeglądarka otrzymuje cały zbiór przeznaczony do przeszukiwania. Wszystkie operacje muszą działać na tym samym zestawie danych. Nie pobieraj jednej strony z API, by później lokalnie ją sortować i przedstawiać jako sortowanie całej tabeli.
Kontrolujemy tylko stan potrzebny poza wewnętrznymi mechanizmami tabeli. Paginacja może pozostać stanem wewnętrznym, dlatego ustawiamy ją wyłącznie w initialState. W v9 reaktywny odczyt odbywa się przez table.state.pagination, a nie przez znane z v8 table.getState().
Dane z Server Component
Server Component powinien wykonać autoryzację, pobrać tylko dozwolone pola i znormalizować typy ORM. Ukrycie e-maila przez CSS lub mechanizm widoczności kolumn nie usuwa go z payloadu RSC. Szczególnej uwagi wymaga Prisma.Decimal: konwersja Number(order.total) może utracić precyzję.
Code
// app/dashboard/orders/order-dto.tsimport type { Prisma } from '@prisma/client'export function toMinorUnits(value: Prisma.Decimal) { const minor = value.mul(100) if (!minor.isInteger() || minor.abs().greaterThan(Number.MAX_SAFE_INTEGER)) { throw new Error('Kwota nie mieści się w bezpiecznym DTO') } return minor.toNumber()}
Mapper toOrderDto() powinien dodatkowo skopiować wybrane pola, użyć toMinorUnits(order.total) i zamienić datę przez toISOString(). Jego typ wejściowy wyprowadź z walidowanego select Prisma, aby mapper i zapytanie zawsze miały ten sam kontrakt.
Tryb serwerowy: paginacja i filtry w URL
Jeżeli klient otrzymuje tylko jedną stronę, baza musi wykonać filtrowanie, sortowanie i paginację w tej kolejności. Parametr sortowania mapuj przez whitelistę. Nigdy nie składaj nazwy pola ORM bezpośrednio z wartości przesłanej w URL.
Poniższy fragment zakłada PostgreSQL. mode: 'insensitive' jest zależne od providera Prisma; w MySQL zwykle decyduje collation i ta właściwość nie jest dostępna.
toOrderDto() może używać tego samego sprawdzonego mapowania kwoty i daty co wcześniejszy wariant. searchParams w stronie App Routera jest obietnicą, a jego odczyt powoduje dynamiczne renderowanie trasy. To oczekiwane dla widoku zależnego od zapytania do bazy.
Po stronie klienta pozostaw ten sam renderer nagłówków i wierszy. Zmień wyłącznie źródło stanu i konfigurację useTable. Helper replaceQuery() klonuje window.location.search, nanosi przekazane zmiany i wywołuje router.replace(..., { scroll: false }); dzięki temu równoległa zmiana filtra nie nadpisuje nowszego sortowania.
manualFiltering, manualSorting i manualPagination nie wykonują zapytania. Informują bibliotekę, że przekazane wiersze są już przetworzone. Input wyszukiwarki może mieć lokalny stan i po 300 ms wywoływać replaceQuery({ page: null, filter }); props globalFilter powinien synchronizować go po nawigacji wstecz lub dalej. Reset page przy zmianie filtra lub sortowania jest obowiązkiem aplikacji. enableSortingRemoval: false zapobiega stanowi, w którym UI usuwa sortowanie, a backend natychmiast przywraca domyślny porządek.
W kodzie produkcyjnym wydziel sam markup do wspólnego komponentu renderującego instancję tabeli. Nie duplikuj ponad stu linii <thead>, <tbody> i paginacji tylko dlatego, że zmieniło się źródło danych.
Stabilność, wydajność i bezpieczeństwo zapytań
Przy paginacji offsetowej dodaj unikalny klucz rozstrzygający remisy. Sortowanie wyłącznie po dacie albo kwocie może zmieniać kolejność rekordów między zapytaniami. W przykładzie każda pozycja SORT_MAP kończy się sortowaniem po id w tym samym kierunku.
Duży skip jest kosztowny. Jeżeli użytkownik przechodzi głównie do następnej i poprzedniej partii, rozważ paginację kursorową. Dokładne count() również może być drogie; czasem wystarczy pobrać PAGE_SIZE + 1 rekordów i poinformować, czy istnieje następna strona.
Zapytanie count() i późniejsze findMany() mogą zobaczyć nieco inny stan przy równoległych zapisach. Jeżeli dokładna spójność liczby i strony jest wymaganiem biznesowym, użyj transakcji z odpowiednim poziomem izolacji i przygotuj obsługę konfliktów. Nie podnoś izolacji automatycznie dla każdej tabeli, bo ma to koszt.
Indeksy dobieraj na podstawie planów zapytań. Dla wyszukiwania PostgreSQL przez contains samo zwykłe B-tree zwykle nie wystarczy; przy dużych tabelach warto rozważyć indeksy trigramowe albo wyszukiwarkę przeznaczoną do tego zadania.
Dostępność, responsywność i wirtualizacja
Semantyczne <table>, <th scope="col"> i <caption> zachowują relacje między danymi. Sortowanie umieszczaj w prawdziwym <button> i wystawiaj przez aria-sort. Sam onClick na <th> nie zapewnia poprawnej obsługi klawiatury.
Checkbox częściowego zaznaczenia wymaga dwóch warstw: informacji dla technologii asystujących oraz właściwości DOM indeterminate dla prezentacji wizualnej. Komponent SelectionCheckbox ustawia natywną właściwość przez ref zamiast udawać stan wyłącznie ikoną.
Na małym ekranie zacznij od kontenera overflow-x-auto. Ukrywanie kolumn może poprawić ergonomię, ale nie może ukrywać danych poufnych, które nie powinny znaleźć się w przeglądarce.
TanStack Table nie wirtualizuje DOM samodzielnie. Przy setkach widocznych wierszy połącz go z TanStack Virtual. Wirtualizacja nie zastępuje paginacji serwerowej: ogranicza liczbę elementów DOM, ale nie zmniejsza automatycznie odpowiedzi z bazy.
Co testować
Kierunek sortowania i zgodność identyfikatorów kolumn z whitelistą backendu.
Reset strony po zmianie filtra, statusu, sortowania i rozmiaru strony.
Parametry puste, powtórzone, spoza whitelisty i numer strony poza zakresem.
Pusty wynik, stan ładowania oraz nawigację wstecz i dalej.
Sortowanie klawiaturą, nazwę dostępną checkboxów i stan indeterminate.
Brak dostępu do strony i operacji masowych bez wymaganych uprawnień.
Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
TanStack Table jest biblioteką headless: dostarcza stan i logikę tabeli, ale nie narzuca komponentów ani stylów. Dobrze pasuje do własnego design systemu. AG Grid oferuje więcej gotowych funkcji klasy enterprise, a MUI DataGrid jest naturalnym wyborem w aplikacji opartej na Material UI.
Czy mogę eksportować dane tabeli do CSV?
Tak. W TanStack Table v9 table.getPrePaginatedRowModel().rows zwraca wiersze po filtrowaniu i sortowaniu, ale przed paginacją. W trybie serwerowym tabela zna tylko pobraną stronę, dlatego eksport całego wyniku powinien realizować osobny endpoint z tymi samymi filtrami i autoryzacją.
Jak obsłużyć tabelę na urządzeniach mobilnych?
Najprostszy poprawny wariant to semantyczna tabela w kontenerze z overflow-x-auto. Można też ukrywać mniej istotne kolumny albo przygotować osobny widok listy, zachowując dostępność danych.
Kiedy przejść z paginacji klienckiej na serwerową?
Nie istnieje uniwersalny próg rekordów. Uwzględnij rozmiar odpowiedzi, liczbę kolumn, koszt zapytania, pamięć urządzeń docelowych i czas operacji. Tryb serwerowy jest potrzebny, gdy klient nie powinien pobierać całego zbioru albo operacje muszą obejmować dane spoza bieżącej strony.
Dlaczego dane pobierać w Server Component, a tabelę renderować w Client Component?
Server Component może odczytać bazę bez dodatkowego Route Handlera. Interaktywna tabela pozostaje Client Componentem. Do klienta należy przekazywać tylko serializowalne DTO, a definicje kolumn zawierające funkcje importować bezpośrednio w komponencie klienckim.
O autorze
Maciej Sala
Maciej Sala — konsultant technologiczny produktów cyfrowych i web developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty.
Data fetching , czyli sztuka sensownego pobierania danych, to bodajże najstarszy problem w świecie Reacta. Napisanie podstawowego useEffect wraz z fetch jest proste, ale dopisanie do niego logiki, która poprawnie obsłuży cache, usunie zdublowane zapytania, bezbłędnie zadba o powtórzenia w razie błędów retry i elegancko wybroni się przed nadpisywaniem starych wyników race conditions ... to już grubszy temat.
Maciej Sala
Founder StriveLab
Użytkownik ustawia trzy filtry, znajduje idealny wynik, wysyła link koledze — a tamten widzi pustą wyszukiwarkę. Bo filtry siedziały w useState i zginęły poza jego przeglądarką. URL state rozwiązuje to u źródła: cały stan filtrów żyje w adresie, więc przetrwa odświeżenie, udostępnienie i przycisk „wstecz”, a do tego jest czytelny dla Server Components. Pokazuję, jak zbudować taką wyszukiwarkę — z debounce , skeletonami, walidacją parametrów i SEO.
Maciej Sala
Founder StriveLab
Frontend rzadko kończy się na komponencie i jednym fetch , a im bliżej realnego produktu, tym częściej jakość UI zależy od zachowania backendu. Jak API paginuje dane, jak zwraca błędy, jak kontroluje dostęp, co robi po przekroczeniu limitu czasu i czy potrafi bezpiecznie przyjąć ponowione żądanie.