Web razvoj
ReactTanStack TableVirtualizationInfinite ScrollReact QueryPerformance

Virtualizacija React tablica i beskonačno skrolanje: izrada brzih data gridova s TanStackom (vodič za 2026.)

AO
Adrijan Omićević
·15 min čitanja

# Što ćete naučiti#

Virtualizacija React tablica je razlika između grida koji djeluje trenutačno i onoga koji gubi frameove, zakucava CPU i pretvara interakcije korisnika u traljavo, trzavo iskustvo.

U ovom vodiču izgradit ćete produkcijski spreman obrazac s TanStack Table uz TanStack Virtual i React Query: virtualizirano renderiranje, beskonačno skrolanje potkrijepljeno paginacijom na serveru, sortiranje i filtriranje na serveru, stanje sinkronizirano s URL-om, stanje selekcije koje preživljava refetcheve te optimistična ažuriranja za izmjene i grupne akcije.

Također ćete vidjeti gdje timovi često nazaduju s performansama i kako to spriječiti profiliranjem i ispravnim obrascima memoizacije. Za dublju dijagnostiku performansi u Reactu pogledajte profiliranje performansi u Reactu, memoizacija i obrasci renderiranja.

# Zašto je virtualizacija važna za data gridove#

Renderiranje velikih skupova podataka nije “samo problem dohvaćanja”. Primarno je to problem DOM-a.

Čak i moderni preglednici imaju poteškoća kada renderirate tisuće row nodova s više ćelija, ikona, inputa i uvjetnog formatiranja. Grid s 10.000 redaka i 12 stupaca lako proizvede 120.000 ćelija. Ako svaka ćelija u prosjeku ima samo 2 DOM noda, to je oko 240.000 nodova, prije nego dodate wrappere i interaktivne komponente.

Virtualizacija to rješava tako da renderira samo ono što je vidljivo plus mali overscan buffer. U praksi to znači stabilnu veličinu DOM-a poput 40 do 120 redaka, čak i ako dataset ima 200.000 redaka.

Ciljevi performansi koje biste trebali gađati#

MetrijaDobar ciljZašto je bitno
Renderirani redci u DOM-u40 do 120Održava layout i paint jeftinima
Budžet framea pri skrolanju16,7 ms po frameuPotrebno za 60 fps skrolanje
Veličina stranice na serveru50 do 200 redakaOdržava payload razumnim i smanjuje round tripove
Debounce za filtre200 do 400 msSprječava “oluju” requestova tijekom tipkanja
Stabilan ID retkaObaveznoOmogućuje postojanost selekcije i ispravna ažuriranja cachea

🎯 Ključna poruka: Virtualizacija smanjuje DOM posao, ne mrežni posao. Uparite je s paginacijom na serveru i cachingom kako biste riješili obje polovice problema.

# Pregled arhitekture: TanStack Table, Virtual i React Query#

Brz grid tipično ima tri sloja:

  1. 1
    Stanje tablice i logika stupaca uz TanStack Table.
  2. 2
    Renderiranje samo vidljivih redaka uz TanStack Virtual.
  3. 3
    Dohvaćanje podataka i cache uz React Query, koristeći infinite queryje za beskonačno skrolanje i mutacije za izmjene.

Preporučeni stack#

BrigaBibliotekaNapomena
Logika tabliceTanStack TableHeadless, podržava sortiranje/filtriranje na serveru
VirtualizacijaTanStack VirtualRadi s varijabilnim visinama redaka, overscan
Dohvaćanje podatakaReact QueryCaching, paginacija, optimistična ažuriranja
URL sinkronizacijaNext.js router ili vaš routerTretirajte URL kao izvor istine

Ako vaša aplikacija koristi Next.js App Router, strategija sinkronizacije URL-a treba se uklopiti u način na koji već rukujete server komponentama i client komponentama. Ako birate između data biblioteka, pročitajte React Query vs SWR u Next.js App Routeru i odaberite jedan dosljedan pristup.

# Preduvjeti#

ZahtjevVerzijaNapomena
React18+Ponašanje concurrent renderiranja je važno
TanStack Tablev8+Ovdje korišteni API-ji prate v8 obrasce
TanStack Virtualv3+Virtualizer hook-based API
React Queryv5+Infinite queryji i mutation API-ji
TypeScriptPreporučenoPomaže da ID-jevi redaka i modeli budu ispravni

# Korak 1: Modelirajte server API za paginaciju, sortiranje i filtriranje#

Beskonačno skrolanje je UI ponašanje. Server i dalje treba paginirati.

Kad god je moguće koristite cursor paginaciju, jer se bolje ponaša na velikim skalama i izbjegava trošak dubokih offseta. Za admin dashboarde ili manje datasete offset paginacija može i dalje biti prihvatljiva, ali cursor paginacija je sigurniji default kada broj redaka može narasti na stotine tisuća.

Predloženi oblik API-ja#

PoljeTipPrimjerNapomena
itemsarray[{ id, ... }]Mora sadržavati stabilan id
nextCursorstring ili null"eyJpZCI6..."Null znači da nema više stranica
totalnumber153204Opcionalno, skupo za velike datasete
metaobject{ tookMs: 42 }Korisno za debugiranje

Parametri za sortiranje i filtriranje#

Neka budu eksplicitni i serijalizabilni kako biste ih mogli sinkronizirati s URL-om.

ParamPrimjerNapomena
sortcreatedAt:descJedno sortiranje je najjednostavnije
filters[status]activeKoristite stabilne ključeve
qacmeGlobalna pretraga
cursor...Cursor za beskonačno skrolanje
limit100Veličina stranice

⚠️ Upozorenje: Nemojte slati “blobove stanja tablice” na server. Uvijek šaljite eksplicitne ključeve sortiranja i vrijednosti filtera. To drži API stabilnim i sprječava da refaktori na klijentu razbiju backend logiku.

# Korak 2: Postavite React Query infinite query za paginaciju na serveru#

Cilj je dohvaćati stranice i prikazati ih kao jednu kontinuiranu listu za virtualizer.

Ovo je praktičan obrazac za cursor paginaciju s useInfiniteQuery. Držite query key stabilnim i uključite sortiranje i filtre kako bi cache unosi bili ispravni.

TypeScript
import { useInfiniteQuery } from '@tanstack/react-query';
 
type RowItem = { id: string; name: string; status: string; updatedAt: string };
type Page = { items: RowItem[]; nextCursor: string | null };
 
function useGridData(params: {
  sort: string;
  filters: Record<string, string | undefined>;
  q: string;
  limit: number;
}) {
  return useInfiniteQuery({
    queryKey: ['grid', params],
    initialPageParam: null as string | null,
    queryFn: async ({ pageParam }) => {
      const url = new URL('/api/items', window.location.origin);
      url.searchParams.set('sort', params.sort);
      url.searchParams.set('q', params.q);
      url.searchParams.set('limit', String(params.limit));
      if (pageParam) url.searchParams.set('cursor', pageParam);
      for (const [k, v] of Object.entries(params.filters)) {
        if (v) url.searchParams.set(`filters[${k}]`, v);
      }
      const res = await fetch(url.toString());
      if (!res.ok) throw new Error('Failed to load');
      return (await res.json()) as Page;
    },
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    staleTime: 30_000,
  });
}

Poravnajte stranice za tablicu#

TanStack Table očekuje array. Poravnajte stranice i držite to memoizirano.

TypeScript
import { useMemo } from 'react';
 
function useFlatRows(data: { pages?: { items: RowItem[] }[] } | undefined) {
  return useMemo(() => {
    const pages = data?.pages ?? [];
    return pages.flatMap((p) => p.items);
  }, [data]);
}

Za veće aplikacije tretirajte cache i invalidation kao dizajnersku brigu prve klase. Najčešći problemi pri skaliranju su “refetch oluje”, netočni query keyjevi i mutation odgovori koji ne ažuriraju pravi cache unos. Pogledajte React Query invalidacija cachea, paginacija i mutacije na velikoj skali.

# Korak 3: Konfigurirajte TanStack Table za sortiranje i filtriranje na serveru#

Kad sortirate i filtrirate na serveru, morate isključiti client-side row modele za te operacije. TanStack Table to uredno podržava.

Minimalna postava tablice#

Značajka tablicePostavkaZašto
SortingmanualSorting: trueServer je vlasnik poretka
FilteringmanualFiltering: trueServer je vlasnik rezultata filtera
Paginationrješava React QueryInfinite stranice, ne paging tablice

Primjer konfiguracije:

TypeScript
import {
  getCoreRowModel,
  useReactTable,
  type ColumnDef,
  type SortingState,
  type ColumnFiltersState,
} from '@tanstack/react-table';
 
function useServerTable(args: {
  data: RowItem[];
  columns: ColumnDef<RowItem>[];
  sorting: SortingState;
  columnFilters: ColumnFiltersState;
  onSortingChange: (s: SortingState) => void;
  onColumnFiltersChange: (f: ColumnFiltersState) => void;
}) {
  return useReactTable({
    data: args.data,
    columns: args.columns,
    state: {
      sorting: args.sorting,
      columnFilters: args.columnFilters,
    },
    onSortingChange: args.onSortingChange,
    onColumnFiltersChange: args.onColumnFiltersChange,
    getCoreRowModel: getCoreRowModel(),
    manualSorting: true,
    manualFiltering: true,
  });
}

Pretvaranje TanStack stanja u API parametre#

Mapiranje neka bude predvidljivo. Na primjer, dopustite jedno aktivno sortiranje.

TypeScript
function sortingToSortParam(sorting: { id: string; desc: boolean }[]) {
  if (!sorting?.length) return 'updatedAt:desc';
  const s = sorting[0];
  return `${s.id}:${s.desc ? 'desc' : 'asc'}`;
}

💡 Savjet: Držite server sort ključeve jednakima column accessor ključevima. Svaki sloj “prevođenja” je još jedno mjesto za bugove i još jedan razlog da sinkronizacija s URL-om postane krhka.

# Korak 4: Dodajte TanStack Virtual za virtualizaciju redaka#

Virtualizirate renderirane retke, ne dohvaćene stranice. Infinite query može držati tisuće itema, dok DOM drži samo vidljivi izrez.

Ključno je izmjeriti scroll container i pitati virtualizer koje indexe treba renderirati.

Postava virtualizera#

TypeScript
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
 
function useRowVirtualizer(rowCount: number) {
  const parentRef = useRef<HTMLDivElement | null>(null);
 
  const rowVirtualizer = useVirtualizer({
    count: rowCount,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 44,
    overscan: 8,
  });
 
  return { parentRef, rowVirtualizer };
}

Renderiranje virtualnih redaka s apsolutnim pozicioniranjem#

Ovaj obrazac drži skrolanje nativnim i glatkim.

TypeScript
const virtualItems = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
 
// Inside render:
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
  <div style={{ height: totalSize, position: 'relative' }}>
    {virtualItems.map((v) => {
      const row = table.getRowModel().rows[v.index];
      return (
        <div
          key={row.id}
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            width: '100%',
            transform: `translateY(${v.start}px)`,
          }}
        >
          {/* render cells */}
        </div>
      );
    })}
  </div>
</div>

Varijabilne visine redaka#

Ako se retci mogu prelamati po tekstu ili proširivati, prebacite se na sizing temeljen na mjerenju. Možete izmjeriti svaki red i pozvati rowVirtualizer.measureElement.

Ako želite stabilne performanse, držite visine redaka fiksnima za “normalno” stanje, a proširenje prebacite u detail panel izvan virtualizirane liste.

ℹ️ Napomena: Virtualizacija i sticky headeri su kompatibilni, ali najlakše je kada je header izvan scroll containera, a virtualizira se samo body.

# Korak 5: Okidač beskonačnog skrolanja na temelju virtualizer raspona#

Ne okidajte fetch na temelju scroll pixel vrijednosti. Okidajte na temelju “blizu kraja renderiranog raspona”.

Kad se indeks zadnjeg virtualnog retka približi duljini učitanih redaka, dohvatite sljedeću stranicu.

TypeScript
import { useEffect } from 'react';
 
function useFetchNextOnScroll(args: {
  virtualItems: { index: number }[];
  rowsLength: number;
  hasNextPage: boolean;
  isFetchingNextPage: boolean;
  fetchNextPage: () => void;
}) {
  useEffect(() => {
    const last = args.virtualItems[args.virtualItems.length - 1];
    if (!last) return;
 
    const threshold = 15;
    const shouldLoad =
      last.index >= args.rowsLength - 1 - threshold &&
      args.hasNextPage &&
      !args.isFetchingNextPage;
 
    if (shouldLoad) args.fetchNextPage();
  }, [
    args.virtualItems,
    args.rowsLength,
    args.hasNextPage,
    args.isFetchingNextPage,
    args.fetchNextPage,
  ]);
}

Reset pri promjeni “sort ili filter”#

Kad se promijeni sort ili filteri, želite:

  1. 1
    Resetirati na prvu stranicu.
  2. 2
    Skrolati na vrh.
  3. 3
    Izbjeći prikaz starih itema pomiješanih s novim poretkom.

To uglavnom dobivate “besplatno” ako query key uključuje sort i filtre, jer React Query napravi novi cache unos i stare stranice se neće nadodavati u novi query.

Također eksplicitno skrolajte na vrh:

TypeScript
function scrollToTop(parent: HTMLDivElement | null) {
  if (parent) parent.scrollTo({ top: 0 });
}

# Korak 6: Sačuvajte stanje selekcije kroz stranice, refetcheve i sortiranje#

Selekcija puca kada je vezana uz indeks retka. Mora biti vezana uz stabilan ID retka.

Spremite selekciju u Set keyan po row.id, neovisno o trenutno učitanim stranicama.

Obrazac za selection store#

OdlukaPreporučenoZašto
Key poidStabilno kroz paginaciju i sortiranje
PohranauseState ili vanjski storeOvisi koliko je globalno
DefaultpraznoIzbjegava iznenađenja tipa “select all”
Bulk akcijerade nad ID-jevimaServer može primiti liste ID-jeva

Primjer:

TypeScript
import { useCallback, useMemo, useState } from 'react';
 
function useRowSelection() {
  const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
 
  const toggle = useCallback((id: string) => {
    setSelectedIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);
 
  const isSelected = useCallback(
    (id: string) => selectedIds.has(id),
    [selectedIds]
  );
 
  const selectedCount = selectedIds.size;
 
  return { selectedIds, toggle, isSelected, selectedCount, setSelectedIds };
}

Odaberi sve vidljivo vs odaberi sve filtrirano#

Budite eksplicitni u UI-ju, jer to znače različite stvari.

AkcijaŠto odabireImplementacija
Select visibleSamo učitane stavkeDodajte ID-jeve iz poravnatih redaka
Select all filteredCijeli server rezultatServer-side flag ili selekcija temeljena na queryju

Za “select all filtered” nemojte povlačiti sve ID-jeve na klijent. Umjesto toga koristite server-side bulk operaciju koja se primjenjuje na trenutni filter query, a na klijentu držite skup “izuzetih ID-jeva” ako trebate iznimke.

⚠️ Upozorenje: “Select all” na beskonačnom skrolanju često postane skriveni bug performansi gdje klijent pokušava akumulirati desetke tisuća ID-jeva. Preferirajte server-side bulk operacije.

# Korak 7: URL sinkronizacija za sortiranje, filtre i pretragu#

URL sinkronizacija čini grid dijeljivim i čuva stanje kroz refresh. Također smanjuje interne bugove stanja jer imate jedan izvor istine.

Što staviti u URL#

StanjeStaviti u URLNapomena
Globalna pretraga qDaDebounce ažuriranja
Filteri stupacaDaSamo serijalizabilne primitive
SortingDaKoristite col:dir
CursorObično neCursori su efemerni; reset na reload
Odabrani ID-jeviNePreveliko i osjetljivo za privatnost

Helperi za query string#

Kodiranje držite jednostavnim. Koristite stringove odvojene zarezom, ne JSON.

TypeScript
function encodeFilters(filters: Record<string, string | undefined>) {
  return Object.entries(filters)
    .filter(([, v]) => Boolean(v))
    .map(([k, v]) => `${k}:${encodeURIComponent(String(v))}`)
    .join(',');
}
 
function decodeFilters(value: string) {
  const out: Record<string, string> = {};
  if (!value) return out;
  for (const part of value.split(',')) {
    const [k, v] = part.split(':');
    if (k && v) out[k] = decodeURIComponent(v);
  }
  return out;
}

Debounced ažuriranja URL-a#

Ažurirajte URL kad korisnik prestane tipkati, ne na svaki pritisak tipke. Debouncing također smanjuje query churn jer query keyjevi često ovise o tim parametrima.

Ako niste sigurni dolaze li rerenderi iz URL stanja ili stanja tablice, profilirajte. Najčešća greška je “stanje spremljeno na previše mjesta”, što prisiljava dodatne rendere i razbija memoizaciju. Obrasci u profiliranju performansi u Reactu, memoizaciji i obrascima renderiranja pomažu vam identificirati točnu komponentu koja uzrokuje churn.

# Korak 8: Optimistična ažuriranja za izmjene i grupne akcije#

Optimistična ažuriranja čine grid trenutačnim. Ključ je ažurirati svaku stranicu u cacheu infinite queryja, ne samo trenutno vidljivi izrez.

Ažuriranje stavke kroz stranice#

Ova mutacija ažurira cachirane infinite query podatke tako da mapira kroz stranice.

TypeScript
import { useMutation, useQueryClient } from '@tanstack/react-query';
 
function useUpdateItem(params: { sort: string; filters: any; q: string; limit: number }) {
  const qc = useQueryClient();
 
  return useMutation({
    mutationFn: async (input: { id: string; patch: Partial<RowItem> }) => {
      const res = await fetch(`/api/items/${input.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input.patch),
      });
      if (!res.ok) throw new Error('Update failed');
      return (await res.json()) as RowItem;
    },
    onMutate: async (input) => {
      const key = ['grid', params] as const;
      await qc.cancelQueries({ queryKey: key });
 
      const previous = qc.getQueryData<any>(key);
 
      qc.setQueryData(key, (old: any) => {
        if (!old?.pages) return old;
        return {
          ...old,
          pages: old.pages.map((p: any) => ({
            ...p,
            items: p.items.map((it: RowItem) =>
              it.id === input.id ? { ...it, ...input.patch } : it
            ),
          })),
        };
      });
 
      return { previous, key };
    },
    onError: (_err, _input, ctx) => {
      if (ctx?.previous) qc.setQueryData(ctx.key, ctx.previous);
    },
    onSettled: (_data, _err, _input, ctx) => {
      if (ctx?.key) qc.invalidateQueries({ queryKey: ctx.key });
    },
  });
}

Optimistični delete uz čišćenje selekcije#

Nakon brisanja uklonite ID iz selekcije i uklonite ga iz cachiranih stranica.

KorakAkcijaZašto
1Optimistički ukloniti iz cacheaTrenutačan UI
2Ukloniti iz selection setaSprječava “ghost” selekciju
3Invalidirati queryUskladiti sa serverom

Kod mutacija držite malim i predvidljivim. Za više obrazaca, uključujući pagination-aware pravila invalidacije, pogledajte React Query invalidacija cachea, paginacija i mutacije na velikoj skali.

💡 Savjet: Ako izmjena promijeni sort poredak, optimistična ažuriranja mogu privremeno prikazati redak na “krivoj” poziciji. Za sortirajuće stupce preferirajte brzu invalidaciju nakon mutate, ili primijenite optimističnu izmjenu i prihvatite kratko neslaganje do refetcha.

# Česte zamke i kako ih izbjeći#

  1. 1

    Keyanje redaka po indeksu
    Svugdje koristite stabilne id ključeve. Ključevi po indeksu razbijaju selekciju, re-use u virtualizaciji i mogu stvoriti vizualne glitchove pri sortiranju.

  2. 2

    Miješanje client-side i server-side sortiranja
    Ako server sortira, isključite client sort row modele i držite sorting state tablice čisto kao server parametar.

  3. 3

    Okidanje fetcha direktno na scroll evente
    Scroll eventi se pale vrlo često. Koristite virtualizer raspon da odlučite kada ste blizu kraja.

  4. 4

    Neograničeni query keyjevi
    Ne stavljajte ne-serijalizabilne objekte u query keyjeve. Parametre držite stabilnima i plitkima da caching radi.

  5. 5

    Renderiranje teških cell komponenti unutar virtualizirane liste
    Virtualizacija smanjuje DOM, ali skupi renderi ćelija i dalje bole. Memoizirajte cell renderere i izbjegavajte closure po ćeliji gdje god možete. Koristite workflow profiliranja u profiliranju performansi u Reactu, memoizaciji i obrascima renderiranja.

# Ključne poruke#

  • Koristite virtualizaciju React tablica kako biste ograničili veličinu DOM-a na mali vidljivi prozor, čak i kada su podaci u desecima tisuća.
  • Tretirajte beskonačno skrolanje kao UI sloj iznad cursor paginacije na serveru koristeći React Query infinite queryje.
  • Implementirajte sortiranje i filtriranje na serveru s manualSorting i manualFiltering, te mapirajte stanje tablice u eksplicitne API parametre.
  • Očuvajte selekciju tako da je spremate u Set stabilnih ID-jeva redaka, nikad po indeksu retka, i rješavajte “select all filtered” na serveru.
  • Sinkronizirajte pretragu, filtre i sortiranje u URL query string kako bi grid bio dijeljiv i otporan kroz reloade.
  • Koristite optimistična ažuriranja tako da patchate svaku stranicu u cacheu infinite queryja, a zatim invalidirate kako biste se uskladili s istinom servera.

# Zaključak#

Brz data grid je sustav: virtualizacija za renderiranje, paginacija i caching za podatke te predvidivo upravljanje stanjem za selekciju, URL sinkronizaciju i mutacije.

Ako želite da Samioda implementira produkcijski TanStack grid u vašoj React ili Next.js aplikaciji, uključujući filtriranje i sortiranje na serveru, stanje sinkronizirano s URL-om i pouzdana optimistična ažuriranja, kontaktirajte nas putem samioda.com i podijelite veličinu dataseta, potrebne stupce i backend ograničenja.

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.

Više iz kategorije Web razvoj

Sve

Trebate pomoć s projektom?

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