# Što ćete izgraditi#
Ovaj vodič pokriva React obrasce za tablice podataka koji izdrže kada tablica naraste s nekoliko stotina redaka na desetke tisuća. Implementirat ćete skalabilnu arhitekturu koristeći TanStack Table za stanje i TanStack Virtual za windowing, uz obrasce za pinanje stupaca, debounce filtere, sortiranje i paginaciju na strani servera te robusne izvoze.
Ako već koristite infinite scroll ili windowing, krenite s ovim povezanim “deep-dive” tekstovima pa se vratite ovdje kako biste objednili obrasce u jedinstvenu arhitekturu tablice:
# Preduvjeti#
| Zahtjev | Verzija | Napomene |
|---|---|---|
| React | 18+ | Concurrent rendering pomaže u percipiranoj responzivnosti |
| TanStack Table | 8+ | Vodič pretpostavlja v8 obrasce i API-je |
| TanStack Virtual | 3+ | Za virtualizaciju redaka |
| TanStack Query | 5+ | Za server state, cacheiranje i otkazivanje |
| TypeScript | Preporučeno | Tablice je lakše održavati uz tipizirane modele redaka |
ℹ️ Napomena: Performanse tablice prvenstveno ovise o DOM radu. Ni “brz” API nije dovoljan ako renderirate 10.000 redaka s kompleksnim komponentama ćelija. Virtualizacija često donosi najveći dobitak jer smanjuje broj DOM čvorova za redove veličine.
# Osnovna arhitektura: Jedan izvor istine za stanje tablice#
U velikom opsegu većina bugova u tablicama dolazi od duplikacije stanja: URL kaže jedno, lokalno stanje drugo, a server query koristi treći skup parametara. Pouzdan obrazac je tretirati stanje tablice kao jedan serijalizabilan objekt, a zatim iz njega izvoditi sve ostalo.
Model stanja tablice#
Koristite jedinstveni oblik tableState koji odgovara TanStack Table stanju plus poljima specifičnima za server. Držite ga serijalizabilnim kako biste ga mogli spremati u URL, localStorage ili dijeliti između tabova.
| Dio stanja | Primjer polja | Koristi se za |
|---|---|---|
| Paginacija | pageIndex, pageSize | Offset u server queryju i UI |
| Sortiranje | id, desc | Server sortiranje i UI indikatori |
| Filteri po stupcima | columnId, value | Server filtriranje, UI filtera |
| Globalno pretraživanje | query | Server query, search input |
| Pinanje stupaca | left, right nizovi | UX za široke tablice |
| Vidljivost stupaca | hidden skup | Personalizacija |
| Odabir redaka | selectedRowIds | Bulk akcije i izvozi |
Praktičan default je držati vizualne postavke u localStorageu, a stanje koje utječe na query u URL parametrima. To čini linkove djeljivima i prijateljskima za back gumb.
Minimalni tipizirani container za stanje#
type TableQueryState = {
pageIndex: number;
pageSize: number;
sorting: Array<{ id: string; desc: boolean }>;
columnFilters: Array<{ id: string; value: string | number | boolean | string[] }>;
globalQuery: string;
};
type TableUiState = {
columnPinning: { left: string[]; right: string[] };
columnVisibility: Record<string, boolean>;
};
type DataTableState = {
query: TableQueryState;
ui: TableUiState;
};Koristite reducer kako bi ažuriranja bila eksplicitna i grupirana. Lakše ga je testirati nego više settera, a sprječava “polu-ažurirana” stanja kada, primjerice, mijenjate paginaciju i sortiranje zajedno.
type Action =
| { type: "setPage"; pageIndex: number }
| { type: "setPageSize"; pageSize: number }
| { type: "setSorting"; sorting: TableQueryState["sorting"] }
| { type: "setGlobalQuery"; globalQuery: string }
| { type: "setColumnFilters"; columnFilters: TableQueryState["columnFilters"] }
| { type: "setColumnPinning"; columnPinning: TableUiState["columnPinning"] }
| { type: "setColumnVisibility"; columnVisibility: TableUiState["columnVisibility"] };
function reducer(state: DataTableState, action: Action): DataTableState {
switch (action.type) {
case "setPage":
return { ...state, query: { ...state.query, pageIndex: action.pageIndex } };
case "setPageSize":
return { ...state, query: { ...state.query, pageSize: action.pageSize, pageIndex: 0 } };
case "setSorting":
return { ...state, query: { ...state.query, sorting: action.sorting, pageIndex: 0 } };
case "setGlobalQuery":
return { ...state, query: { ...state.query, globalQuery: action.globalQuery, pageIndex: 0 } };
case "setColumnFilters":
return { ...state, query: { ...state.query, columnFilters: action.columnFilters, pageIndex: 0 } };
case "setColumnPinning":
return { ...state, ui: { ...state.ui, columnPinning: action.columnPinning } };
case "setColumnVisibility":
return { ...state, ui: { ...state.ui, columnVisibility: action.columnVisibility } };
default:
return state;
}
}🎯 Ključna poruka: Resetirajte
pageIndexna nulu svaki put kad se promijene filteri ili sortiranje. Ako to ne učinite, čest su izvor praznih stranica i prijava tipa “nedostaju podaci”.
# Paginacija i sortiranje na strani servera koje skalira#
Sortiranje i filtriranje na klijentu su praktični, ali ne skaliraju ako imate milijune zapisa. Obrasci na strani servera drže odgovore predvidljivima i smanjuju potrošnju memorije.
API ugovor#
Izbjegavajte ad-hoc query parametre. Koristite jasan ugovor koji odgovara stanju tablice.
| Parametar | Primjer | Napomene |
|---|---|---|
page | 0 | Zero-based indeks stranice najjednostavniji je uz TanStack |
pageSize | 50 | Držite razuman maksimum, npr. 200 |
sort | createdAt:desc | Podržite više sortova ako treba |
filters | status:active | Za kompleksne filtere preferirajte JSON kodiranje |
q | john | Globalni query string |
Ako su filteri kompleksni, pošaljite ih kao JSON u body zahtjeva za POST-based queryje ili ih enkodirajte kao kompaktan string. Neka bude konzistentno između list queryja i izvoza.
React Query obrazac za podatke tablice#
Koristite query ključeve koji uključuju samo stanje koje utječe na query. UI preferencije poput pinanih stupaca ne bi smjele refetchati podatke.
import { useQuery } from "@tanstack/react-query";
function useUsersTable(query: TableQueryState) {
return useQuery({
queryKey: ["users", query],
queryFn: async ({ signal }) => {
const res = await fetch("/api/users/search", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(query),
signal,
});
if (!res.ok) throw new Error("Request failed");
return res.json() as Promise<{ rows: unknown[]; total: number }>;
},
placeholderData: (prev) => prev,
staleTime: 10_000,
});
}placeholderData drži stare retke vidljivima dok se dohvaćaju novi, što smanjuje layout shift i čini filtriranje bržim na dojam.
⚠️ Upozorenje: Ako u query key uključite neserijalizabilne vrijednosti, ponašanje cachea postaje nepredvidljivo. Držite
querykao običan, stabilan JSON ili ga normalizirajte u string.
Manual mode u TanStack Table#
Kada je server izvor istine, postavite manual modove i proslijedite rowCount.
| TanStack Table opcija | Preporučena vrijednost | Zašto |
|---|---|---|
manualPagination | true | Server određuje stranice |
manualSorting | true | Server određuje sortiranje |
manualFiltering | true | Server primjenjuje filtere |
pageCount ili rowCount | iz API total | Omogućuje ispravan UI za paginaciju |
To sprječava TanStack Table da radi skupe klijentske operacije koje se ne poklapaju sa server rezultatima.
# Debounce filteri bez UX laga ili spama zahtjevima#
Filteri su mjesto gdje se UX i performanse često prvo “slome”. Bez debouncea, svaki pritisak tipke šalje zahtjev. S agresivnim debounceom, UI djeluje tromo. Pravi cilj je: input se ažurira odmah, query se ažurira malo kasnije.
Obrazac: lokalno stanje inputa, debounced commit#
Držite input kontroliranim lokalnim stanjem, a u stanje queryja tablice commitajte tek nakon debouncea.
function useDebouncedValue<T>(value: T, delayMs: number) {
const [debounced, setDebounced] = React.useState(value);
React.useEffect(() => {
const t = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(t);
}, [value, delayMs]);
return debounced;
}Zatim primijenite:
const [searchInput, setSearchInput] = React.useState(state.query.globalQuery);
const debouncedSearch = useDebouncedValue(searchInput, 300);
React.useEffect(() => {
dispatch({ type: "setGlobalQuery", globalQuery: debouncedSearch });
}, [debouncedSearch]);Debounce od 250 do 400 milisekundi je česta polazna točka. Za “typeahead” iskustva koristite 150 do 250 milisekundi, ali pazite da se zahtjevi otkazuju preko signal iz React Queryja.
💡 Savjet: Dodajte “Search” gumb za power user-e koji preferiraju eksplicitan commit, i svejedno ostavite debounce uključen. To smanjuje frustraciju korisnicima na vezama s velikom latencijom.
Normalizacija vrijednosti filtera#
Normalizirajte filtere kako bi server dobivao konzistentne tipove. Primjerice, mapirajte prazne stringove na null i izostavite ih, a vrijednosti multi-selecta normalizirajte u nizove.
| UI kontrola | UI vrijednost | API vrijednost |
|---|---|---|
| Text input | "" | izostavi filter |
| Numeric input | "10" | broj 10 |
| Multi-select | ["a","b"] | ["a","b"] |
| Checkbox | false | izostavi filter ili false ovisno o semantici |
To smanjuje probleme tipa “zašto filtriranje ponekad ne radi” i čini izvoze usklađenima s vidljivom tablicom.
# Virtualizacija s TanStack Virtual: kompromisi i implementacija#
Virtualizacija je često razlika između tablice koja djeluje trenutno i one koja zamrzne preglednik. Renderiranje 5.000 redaka s 20 ćelija po retku može značiti 100.000 ćelija u DOM-u. Čak i ako je svaka ćelija “jednostavna”, taj volumen obično uzrokuje input lag i scroll jank.
Što dobivate virtualizacijom#
Virtualizacija renderira samo vidljive retke plus mali buffer. Ako vaš viewport prikazuje 30 redaka i overscanate za 10, možete renderirati oko 50 redaka umjesto 5.000.
| Metrička | Bez virtualizacije | S virtualizacijom |
|---|---|---|
| Renderirani DOM redci | pageSize ili više | vidljivi redci + overscan |
| Performanse scrolla | često degradiraju nakon 1.000+ redaka | u većini slučajeva stabilne |
| Složenost implementacije | niska | umjerena |
| Složenost pristupačnosti | umjerena | viša |
| Copy-paste selekcija | jednostavna | može biti iznenađujuća |
Osnovni obrazac postavljanja#
Pouzdan pristup je renderirati “table-like” layout sa scroll containerom i unutarnjim spacerom, pa apsolutno pozicionirati redke prema virtual offsetima.
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 8,
});Držite estimateSize blizu stvarnosti. Nepodudaranje uzrokuje vidljivo “poskakivanje” dok se mjeri.
Kompromisi virtualizacije koje morate planirati#
- 1
Dinamične visine redaka
Ako redci lome tekst u više redova ili imaju proširivi sadržaj, visine se mijenjaju. Mjerenje postaje skuplje i scroll pozicija može “skočiti”. Koristite fiksne visine redaka kad god je moguće. - 2
Sticky header i pinani stupci
Sticky header je obično u redu, ali pinani stupci traže pažljivo slojevitost. Pinane ćelije trebaju koristitiposition: stickyi ispravanz-indexkako ne bi treperile pri scrollu. - 3
Navigacija tipkovnicom i čitači ekrana
Virtualizirani redci možda nisu u DOM-u, što može narušiti neka očekivanja pristupačnosti. Ako vaš proizvod zahtijeva intenzivnu tipkovničku navigaciju u tablicama, testirajte rano. - 4
Konzistentnost odabira redaka
Stanje selekcije mora biti neovisno o renderiranim redcima. Spremajte selekciju po stabilnim ID-jevima redaka, ne po indeksu retka.
⚠️ Upozorenje: Virtualizacija ne popravlja sporu logiku renderiranja ćelija. Ako svaka ćelija radi skupo formatiranje ili kompleksne komponente, i dalje ćete imati jank. Memoizirajte teške renderere ćelija i držite ćelije “pure”.
# Pinanje stupaca i UX širokih tablica#
Široke enterprise tablice često padnu na UX-u prije nego na performansama. Pinanje stupaca je značajka visoke vrijednosti jer drži ključne identifikatore i akcije vidljivima.
Strategija pinanja#
Pinanje treba tretirati kao UI preferenciju, ne kao stanje queryja. Spremite ga po korisniku u localStorage ili u korisnički profil.
| Tip stupca | Tipično mjesto | Zašto |
|---|---|---|
| Odabir redaka | pinano lijevo | bulk selekcija ostaje dostupna |
| Primarni identifikator | pinano lijevo | korisnicima treba kontekst tijekom scrolla |
| Status bedževi | lijevo ili sredina | ovisi o workflowu |
| Izbornik akcija | pinano desno | konzistentno mjesto za akcije |
| Numeričke metrike | scrollabilno | korisnici uspoređuju kroz stupce |
Praktična z-index pravila#
Čest problem je da se pinani stupci renderiraju ispod normalnih ćelija tijekom horizontalnog scrolla. Koristite ova jednostavna pravila:
- pinane header ćelije trebaju imati najviši stacking context
- pinane body ćelije trebaju biti iznad ne-pinanih ćelija
- desno pinane akcije trebaju biti iznad svega u bodyju
Držite CSS konzistentnim između headera i bodyja kako ne bi izgledalo “razdvojeno”.
# Zamke performansi i kako ih izbjeći#
Ako želite da tablica djeluje brzo, pripazite na ove obrasce. Većina timova naleti barem na jedan kad krenu skalirati.
Zamka 1: Nestabilne definicije stupaca#
Ako se stupci ponovno kreiraju pri svakom renderu, TanStack Table radi više posla, a vaše ćelije se nepotrebno rerenderiraju.
Rješenje: omotajte stupce u useMemo i držite ovisnosti minimalnima.
const columns = React.useMemo(() => [
// column defs here
], []);Zamka 2: Skupo formatiranje ćelija pri svakom rerenderu#
Formatiranje datuma, valuta i izvedenih vrijednosti unutar cell renderera se zbraja. Tablica s 50 vidljivih redaka i 20 stupaca je 1.000 ćelija. Ako svaka ćelija radi netrivijalan posao na svakom renderu, osjetit ćete to.
Rješenje: kad je moguće, predizračunajte na serveru ili memoizirajte izvedene vrijednosti po retku.
Zamka 3: Povezivanje stanja filter inputa sa stanjem queryja#
Ako svaki pritisak tipke ažurira query stanje, može triggerati refetch i rerendere. Čak i uz React Query otkazivanje, možete preopteretiti server i pogoršati percipirane performanse.
Rješenje: lokalno stanje inputa + debounced commit, kao ranije.
Zamka 4: “Infinite scroll” bez jasne pozicije i ukupnog broja#
Korisnici moraju znati gdje su. Bez konteksta stranice ili ukupnog broja, ne mogu odgovoriti “koliko rezultata postoji” ili “jesam li došao do kraja”.
Rješenje: prikažite total i dodajte “Back to top” affordance. Za admin alate paginacija je često bolji UX od infinite scrolla.
# Ponovno iskoristiv obrazac izvoza koji prati filtere i sortiranje#
Izvozi u produkciji “padaju” kada se ne poklapaju s onim što korisnik vidi. Izvoz mora ponovno koristiti isto query stanje i sigurno hendlanje velikih datasetova.
Odlučite između klijentskog i serverskog izvoza#
| Tip izvoza | Kada radi | Ograničenja |
|---|---|---|
| CSV na klijentu | podaci su već učitani i mali | potrošnja memorije i sporo stringifyanje za velike izvoze |
| CSV/XLSX na serveru | veliki dataseti ili “izvezi sve rezultate” | treba background job i storage |
| Async job s emailom/linkom | jako veliki izvozi | više infrastrukture, najbolji UX za ogromne datasete |
Praktičan prag: ako izvoz prelazi 5.000 do 20.000 redaka, prebacite ga na server. Generiranje CSV-a u pregledniku može trajati sekunde i zamrznuti UI, posebno na slabijim uređajima.
Obrazac: zahtjev za izvoz koristi isto query stanje#
Napravite funkciju koja pretvara TableQueryState u payload za izvoz. Ključ je konzistentnost: isti filteri i sortiranje kao u table queryju.
type ExportFormat = "csv" | "xlsx";
function buildExportPayload(query: TableQueryState, format: ExportFormat) {
return {
format,
query,
createdAt: new Date().toISOString(),
};
}Tijek serverskog izvoza s pollingom#
- 1POST
/api/exportss payloadom - 2server vraća
jobId - 3pollajte
/api/exports/:jobIddok status ne budeready - 4preuzmite s
downloadUrl
async function startExport(query: TableQueryState, format: "csv" | "xlsx") {
const res = await fetch("/api/exports", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(buildExportPayload(query, format)),
});
if (!res.ok) throw new Error("Export start failed");
return res.json() as Promise<{ jobId: string }>;
}Polling držite jednostavnim i zaustavite kad je spremno:
async function waitForExport(jobId: string) {
for (let i = 0; i < 60; i += 1) {
const res = await fetch(`/api/exports/${jobId}`);
const data = await res.json() as { status: "queued" | "running" | "ready"; downloadUrl?: string };
if (data.status === "ready" && data.downloadUrl) return data.downloadUrl;
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error("Export timed out");
}To održava UI responzivnim i izbjegava long-running zahtjeve koji timeoutaju iza proxyja.
ℹ️ Napomena: Ako već koristite n8n, izvozi su odličan kandidat za automatizaciju. Workflow može generirati datoteke, spremiti ih i poslati email s linkom za preuzimanje, dok vaša aplikacija samo prati status joba.
UX detalji koji sprječavaju support tickete#
- prikažite neblokirajuće stanje napretka, ne modal koji zaključava UI
- uključite opseg izvoza, npr. “Izvoz svih filtriranih rezultata”
- uključite vremenski raspon ili sažetak filtera u naziv datoteke
- logirajte export jobove radi audita ako su podaci osjetljivi
# Sve zajedno: kontrolna lista za skalabilnu tablicu#
Koristite ovo kao pre-production checklist kada isporučujete React obrasce za tablice podataka.
| Područje | “Done” izgleda ovako | Čest problem |
|---|---|---|
| Stanje | jedan serijalizabilan objekt | duplicirano stanje kroz komponente |
| Server query | koristi query key sa query stanjem | refetch petlje zbog nestabilnih ključeva |
| Sortiranje | manual, server-side | nesklad između UI strelica i server sortiranja |
| Filteri | debounced commit + otkazivanje | spam zahtjevima i input lag |
| Virtualizacija | stabilna visina, mjerenje po potrebi | skakanje scrolla zbog pogrešne procjene |
| Pinanje | sticky + ispravan z-index | pinani stupci trepere ili se preklapaju |
| Izvoz | ponovno koristi isto query stanje | izvoz se ne poklapa s vidljivim redcima |
# Ključne poruke#
- Centralizirajte stanje tablice u jedan serijalizabilan objekt i resetirajte
pageIndexna nulu pri promjenama sortiranja i filtera. - Koristite paginaciju, sortiranje i filtriranje na strani servera sa stabilnim React Query ključevima te držite UI-only stanje izvan query ključeva.
- Implementirajte debounce filtere s lokalnim stanjem inputa i odgođenim commitom kako biste izbjegli spam i lag.
- Dodajte virtualizaciju kada broj redaka ili složenost ćelija stvara pritisak na DOM, ali planirajte kompromise oko dinamične visine, pristupačnosti i sticky pinanja.
- Tretirajte izvoze kao first-class značajku: ponovno koristite query stanje, preferirajte server-side jobove za velike datasete i pružite jasan UX feedback.
# Zaključak#
Skaliranje tablica manje je stvar jedne biblioteke, a više dosljednih obrazaca: jedan model stanja, server-driven queryjanje, debounced inputi, virtualizacija tamo gdje se stvarno isplati te izvozi koji se točno poklapaju s onime što korisnik vidi. Ako želite pomoć u implementaciji ovih React obrazaca za tablice podataka u produkcijskoj React ili Next.js aplikaciji, Samioda može dizajnirati arhitekturu tablice, performance budget i export pipeline end-to-end. Javite se putem Samioda i pregledat ćemo vašu trenutnu implementaciju tablice te preporučiti konkretne popravke.
FAQ
Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.
Više iz kategorije Web razvoj
Sve →Testiranje ugovora za React komponente: MSW + Storybook kao živi API mockovi
Praktičan vodič za testiranje ugovora React komponenti pomoću zajedničkih MSW handlera u Storybooku i automatiziranim testovima kako biste spriječili razilaženje mockova, nestabilan UI i regresije u CI-u.
Arhitektura Next.js administratorskog panela za B2B SaaS: RBAC, revizijski zapisi, impersonacija i sigurne masovne radnje
Praktična referentna arhitektura za Next.js admin panele: modeliranje dozvola, revizijski zapisi, sigurna impersonacija i sigurnosne kontrolne liste za masovne radnje, izvoze i rukovanje PII podacima.
Izrada višekoračnog čarobnjaka u Next.js App Routeru uz Server Actions + Zod (bez dodatnog API sloja)
Implementirajte produkcijski spreman Next.js višekoračni obrazac koristeći App Router Server Actions i Zod — s tri strategije stanja (kolačići, DB nacrti, URL), pristupačnim UX‑om, optimističnim prijelazima i robusnim rukovanjem greškama bez dodavanja API sloja.
Trebate pomoć s projektom?
Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.
Povezani članci
Vodič za virtualizaciju u Reactu: Windowing velikih lista i gridova s TanStack Virtual (i kada to ne raditi)
Praktičan vodič za 2026. za virtualizaciju u Reactu s TanStack Virtual: izgradite brze liste i gridove, dodajte beskonačno učitavanje i sticky zaglavlja, profilirajte mjerljiva poboljšanja i izbjegnite česte rubne slučajeve poput dinamičkih visina redaka i zamki pristupačnosti.
UX obrasci u Next.js App Routeru: Error Boundaries, Loading UI i streaming kako treba
Praktičan vodič za otporan UX u Next.js App Routeru: struktura route segmenata, error.tsx, loading.tsx, not-found.tsx i Suspense streaming obrasci za djelomično renderiranje, sigurnije dohvaćanje podataka i manje pomaka layouta.
Virtualizacija React tablica i beskonačno skrolanje: izrada brzih data gridova s TanStackom (vodič za 2026.)
Naučite virtualizaciju tablica u Reactu uz TanStack Table, TanStack Virtual i React Query: učinkovito renderiranje, beskonačno skrolanje, sortiranje/filtriranje na serveru, sinkronizacija s URL-om, postojanost selekcije i optimistična ažuriranja.