Web razvoj
ReactTanStack TableTanStack VirtualPerformanseUXTablice podatakaReact Query

React obrasci za tablice podataka u velikom opsegu: virtualizacija, pinanje stupaca, filteri i izvoz (TanStack Table + Virtual)

AO
Adrijan Omićević
·15 min čitanja

# Š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#

ZahtjevVerzijaNapomene
React18+Concurrent rendering pomaže u percipiranoj responzivnosti
TanStack Table8+Vodič pretpostavlja v8 obrasce i API-je
TanStack Virtual3+Za virtualizaciju redaka
TanStack Query5+Za server state, cacheiranje i otkazivanje
TypeScriptPreporučenoTablice 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 stanjaPrimjer poljaKoristi se za
PaginacijapageIndex, pageSizeOffset u server queryju i UI
Sortiranjeid, descServer sortiranje i UI indikatori
Filteri po stupcimacolumnId, valueServer filtriranje, UI filtera
Globalno pretraživanjequeryServer query, search input
Pinanje stupacaleft, right nizoviUX za široke tablice
Vidljivost stupacahidden skupPersonalizacija
Odabir redakaselectedRowIdsBulk 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#

TypeScript
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.

TypeScript
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 pageIndex na 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.

ParametarPrimjerNapomene
page0Zero-based indeks stranice najjednostavniji je uz TanStack
pageSize50Držite razuman maksimum, npr. 200
sortcreatedAt:descPodržite više sortova ako treba
filtersstatus:activeZa kompleksne filtere preferirajte JSON kodiranje
qjohnGlobalni 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.

TypeScript
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 query kao 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 opcijaPreporučena vrijednostZašto
manualPaginationtrueServer određuje stranice
manualSortingtrueServer određuje sortiranje
manualFilteringtrueServer primjenjuje filtere
pageCount ili rowCountiz API totalOmoguć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.

TypeScript
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:

TypeScript
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 kontrolaUI vrijednostAPI vrijednost
Text input""izostavi filter
Numeric input"10"broj 10
Multi-select["a","b"]["a","b"]
Checkboxfalseizostavi 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čkaBez virtualizacijeS virtualizacijom
Renderirani DOM redcipageSize ili viševidljivi redci + overscan
Performanse scrollačesto degradiraju nakon 1.000+ redakau većini slučajeva stabilne
Složenost implementacijeniskaumjerena
Složenost pristupačnostiumjerenaviša
Copy-paste selekcijajednostavnamož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.

TypeScript
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. 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. 2

    Sticky header i pinani stupci
    Sticky header je obično u redu, ali pinani stupci traže pažljivo slojevitost. Pinane ćelije trebaju koristiti position: sticky i ispravan z-index kako ne bi treperile pri scrollu.

  3. 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. 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 stupcaTipično mjestoZašto
Odabir redakapinano lijevobulk selekcija ostaje dostupna
Primarni identifikatorpinano lijevokorisnicima treba kontekst tijekom scrolla
Status bedževilijevo ili sredinaovisi o workflowu
Izbornik akcijapinano desnokonzistentno mjesto za akcije
Numeričke metrikescrollabilnokorisnici 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.

TypeScript
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 izvozaKada radiOgraničenja
CSV na klijentupodaci su već učitani i malipotrošnja memorije i sporo stringifyanje za velike izvoze
CSV/XLSX na serveruveliki dataseti ili “izvezi sve rezultate”treba background job i storage
Async job s emailom/linkomjako veliki izvoziviš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.

TypeScript
type ExportFormat = "csv" | "xlsx";
 
function buildExportPayload(query: TableQueryState, format: ExportFormat) {
  return {
    format,
    query,
    createdAt: new Date().toISOString(),
  };
}

Tijek serverskog izvoza s pollingom#

  1. 1
    POST /api/exports s payloadom
  2. 2
    server vraća jobId
  3. 3
    pollajte /api/exports/:jobId dok status ne bude ready
  4. 4
    preuzmite s downloadUrl
TypeScript
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:

TypeScript
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
Stanjejedan serijalizabilan objektduplicirano stanje kroz komponente
Server querykoristi query key sa query stanjemrefetch petlje zbog nestabilnih ključeva
Sortiranjemanual, server-sidenesklad između UI strelica i server sortiranja
Filteridebounced commit + otkazivanjespam zahtjevima i input lag
Virtualizacijastabilna visina, mjerenje po potrebiskakanje scrolla zbog pogrešne procjene
Pinanjesticky + ispravan z-indexpinani stupci trepere ili se preklapaju
Izvozponovno koristi isto query stanjeizvoz se ne poklapa s vidljivim redcima

# Ključne poruke#

  • Centralizirajte stanje tablice u jedan serijalizabilan objekt i resetirajte pageIndex na 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

Share
A
Adrijan OmićevićOsnivač i senior developer

Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.

Povezani članci