Web razvoj
ReactPerformanseTanStack VirtualVirtualizacijaBeskonačni scroll

Vodič za virtualizaciju u Reactu: Windowing velikih lista i gridova s TanStack Virtual (i kada to ne raditi)

AO
Adrijan Omićević
·15 min čitanja

# Što ćete izgraditi (i zašto virtualizacija funkcionira)#

Performanse React listi često se uruše iz jednog jednostavnog razloga: DOM postane prevelik. Renderiranje tisuća čvorova povećava layout, ponovni izračun stilova, memoriju i React reconciliation. Virtualizacija to rješava tako da renderira samo ono što je vidljivo u scroll viewportu plus mali buffer.

U praksi, virtualizacija često smanjuje broj DOM čvorova za 90 do 99 posto. Na primjer, lista od 10.000 redaka renderirana “normalno” može lako stvoriti 10.000 do 50.000 DOM čvorova ovisno o složenosti reda. Virtualizirana lista može držati montiranih samo 30 do 120 redaka u bilo kojem trenutku — što je smanjenje s 10.000 na oko 60 renderiranih redaka za tipične viewporte.

Ovaj vodič pokazuje kako implementirati:

  • Virtualizaciju lista i gridova visokih performansi s TanStack Virtual
  • Beskonačno učitavanje koje ne trza tijekom brzog skrolanja
  • Sticky zaglavlja koja ostaju poravnata dok je sadržaj “windowan”
  • Profiliranje kako biste dokazali mjerljiva poboljšanja
  • Produkcijsku checklistu za rubne slučajeve: dinamičke visine, resize i pristupačnost

Za dublje obrasce React performansi koji nadopunjuju virtualizaciju, pogledajte profiliranje React performansi, memoizacija, obrasci renderiranja, a za šira razmatranja performansi na razini cijele stranice pročitajte optimizacija performansi web stranice.

# Kada virtualizacija pomaže (a kada odmaže)#

Virtualizacija je najvrjednija kada je usko grlo DOM i trošak renderiranja. Manje je vrijedna kada je usko grlo mreža, skupe slike ili težak CPU rad po itemu koji se i dalje događa za svaki vidljivi red.

Koristite virtualizaciju kada#

ScenarijTipični simptomiOčekivani učinak
Lista/log viewer s 1.000+ redakaTrzanje pri skrolanju, input lag, dugi commitovi u React ProfileruVeliko smanjenje DOM-a, glađi scroll
Data tablica s mnogo redaka i stupacaSpor početni render, velika potrošnja memorijeVeliko poboljšanje, posebno uz virtualizaciju stupaca
Grid kartica (thumbnailovi, proizvodi)CPU skokovi tijekom skrolanjaManje layout i repaint rada
Beskonačni feed s paginacijomTrzanje pri dodavanju novih stranicaStabilno skrolanje uz buffered overscan

Izbjegnite ili odgodite virtualizaciju kada#

ScenarijZašto može biti loš fitBolji pristup
Manje od ~200 jednostavnih stavkiKompleksnost nadmašuje dobitakOstavite jednostavno, optimizirajte renderiranja
Redovi trebaju nativnu pretragu u pregledniku i selekciju preko cijele straniceU DOM-u su samo vidljivi redoviNapravite poseban UI za pretragu ili server-side search
Kompleksno upravljanje fokusom s mnogo interaktivnih kontrola po reduUnmountani elementi gube stanje fokusaRazmislite o paginaciji ili redizajnu interakcija u redu
SEO zahtijeva sav sadržaj u DOM-uVirtualizacija skriva sadržajSSR-ajte sažetak, paginirajte ili renderirajte manje stavki

🎯 Ključna poruka: Virtualizacija je alat za UI performanse, ne alat za dohvat podataka. Koristite je za smanjenje DOM-a i render rada, a zatim je uparite s paginacijom ili beskonačnim učitavanjem za skaliranje podataka.

# Preduvjeti i postavljanje#

ZahtjevVerzijaNapomene
React18+Radi s concurrent renderingom
TanStack Virtual3+@tanstack/react-virtual
Browser API-jiResizeObserver preporučenPomaže kod dinamičkog mjerenja

Instalacija:

Bash
npm i @tanstack/react-virtual

Implementirat ćete “scroll element” container, a zatim će virtualizer izračunati koji se itemi trebaju renderirati i gdje.

# Korak 1: Virtualizirajte listu s fiksnom visinom (najbrža baza)#

Krenite s fiksnim visinama kad god je moguće. Fiksna visina daje najstabilnije performanse jer nije potrebno runtime mjerenje.

Minimalni primjer liste s fiksnom veličinom#

TSX
import React, { useMemo, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
 
export function VirtualizedList() {
  const parentRef = useRef<HTMLDivElement | null>(null)
 
  const items = useMemo(
    () => Array.from({ length: 10000 }, (_, i) => `Row ${i}`),
    []
  )
 
  const rowVirtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,
    overscan: 8,
  })
 
  return (
    <div
      ref={parentRef}
      style={{ height: 500, overflow: 'auto', border: '1px solid #ddd' }}
    >
      <div
        style={{
          height: rowVirtualizer.getTotalSize(),
          position: 'relative',
        }}
      >
        {rowVirtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: virtualRow.size,
              transform: `translateY(${virtualRow.start}px)`,
              display: 'flex',
              alignItems: 'center',
              padding: '0 12px',
              boxSizing: 'border-box',
              borderBottom: '1px solid #f0f0f0',
            }}
          >
            {items[virtualRow.index]}
          </div>
        ))}
      </div>
    </div>
  )
}

Zašto je ovo važno:

  • getTotalSize() kreira ispravnu visinu skrola bez renderiranja svih redaka.
  • Apsolutno pozicioniranje sprječava preglednik da radi layout za tisuće “sibling” elemenata.
  • overscan sprječava prazne rupe tijekom brzog skrolanja renderiranjem buffera.

Podešavanje overscana s realnim brojkama#

Overscan je kompromis: veći overscan povećava CPU i memoriju, ali smanjuje šansu da vidite “blanking” tijekom brzog skrolanja. Na modernim uređajima, 8 do 20 je dobar početni raspon za liste.

Tip sadržajaPreporučeni overscanRazlog
Jednostavni tekstualni redovi6 do 12Brzo se renderira, mali rizik od praznina
Kompleksni redovi (avatari, meniji)12 do 24Izbjegava vidljivo “pop-in” montiranje
Teški mediji (slike, chartovi)4 do 10Previše overscana može povećati potrošnju memorije

💡 Savjet: Ako korisnici “fling scrollaju” na trackpadu, povećajte overscan prije nego krenete prepisivati row komponente. Često je to najjeftiniji fix.

# Korak 2: Virtualizirajte grid (kartice, galerije, dashboardi)#

Gridovi trebaju konzistentnu strategiju pozicioniranja. Najlakši pristup je virtualizirati retke, gdje svaki “red” sadrži više stupaca. To virtualizer čini jednodimenzionalnim i obično daje dobre performanse.

Virtualizacija grida batchanjem redova#

TSX
import React, { useMemo, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
 
export function VirtualizedGrid() {
  const parentRef = useRef<HTMLDivElement | null>(null)
 
  const colCount = 4
  const cardHeight = 180
 
  const cards = useMemo(
    () => Array.from({ length: 5000 }, (_, i) => ({ id: i, title: `Card ${i}` })),
    []
  )
 
  const rowCount = Math.ceil(cards.length / colCount)
 
  const rowVirtualizer = useVirtualizer({
    count: rowCount,
    getScrollElement: () => parentRef.current,
    estimateSize: () => cardHeight,
    overscan: 6,
  })
 
  return (
    <div ref={parentRef} style={{ height: 700, overflow: 'auto' }}>
      <div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
        {rowVirtualizer.getVirtualItems().map((vRow) => {
          const startIndex = vRow.index * colCount
          const rowItems = cards.slice(startIndex, startIndex + colCount)
 
          return (
            <div
              key={vRow.key}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: vRow.size,
                transform: `translateY(${vRow.start}px)`,
                display: 'grid',
                gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
                gap: 12,
                padding: 12,
                boxSizing: 'border-box',
              }}
            >
              {rowItems.map((c) => (
                <div
                  key={c.id}
                  style={{
                    height: cardHeight - 24,
                    border: '1px solid #e5e5e5',
                    borderRadius: 10,
                    padding: 12,
                    boxSizing: 'border-box',
                    background: 'white',
                  }}
                >
                  <strong>{c.title}</strong>
                </div>
              ))}
            </div>
          )
        })}
      </div>
    </div>
  )
}

Ako trebate pravu dvodimenzionalnu virtualizaciju (mnogo redaka i mnogo stupaca), TanStack Virtual to može s dva virtualizera — jednim za retke i jednim za stupce. To je posebno relevantno za spreadsheets i široke tablice te se dobro uparuje s TanStack Table. Za taj puni scenarij pogledajte virtualizacija React tablica i beskonačni scroll s TanStack Table.

# Korak 3: Dodajte beskonačno učitavanje bez “glitchanja” skrola#

Virtualizirana lista i dalje može trzati kada dodajete nove stranice ako blokirate main thread ili okinete velika rerenderiranja. Cilj je dohvatiti ranije, efikasno appendati i zadržati stabilne item keyeve.

Obrazac: sentinel red plus prefetch prag#

  • Rezervirajte jedan dodatni “loading” red na kraju.
  • Kada korisnik skrola blizu kraja, dohvatite sljedeću stranicu.
  • Održite stabilno renderiranje s overscan i prefetch pragom.
TSX
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
 
type Row = { id: string; label: string }
 
export function VirtualizedInfiniteList() {
  const parentRef = useRef<HTMLDivElement | null>(null)
  const [rows, setRows] = useState<Row[]>(() =>
    Array.from({ length: 200 }, (_, i) => ({ id: String(i), label: `Row ${i}` }))
  )
  const [isLoading, setIsLoading] = useState(false)
  const hasMore = rows.length < 5000
 
  const count = hasMore ? rows.length + 1 : rows.length
 
  const v = useVirtualizer({
    count,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 44,
    overscan: 12,
  })
 
  const virtualItems = v.getVirtualItems()
  const lastItem = virtualItems[virtualItems.length - 1]
 
  useEffect(() => {
    if (!lastItem) return
    const isAtEnd = lastItem.index >= rows.length - 1
    if (!isAtEnd || isLoading || !hasMore) return
 
    setIsLoading(true)
    setTimeout(() => {
      setRows((prev) => {
        const next = prev.length
        const more = Array.from({ length: 200 }, (_, i) => ({
          id: String(next + i),
          label: `Row ${next + i}`,
        }))
        return prev.concat(more)
      })
      setIsLoading(false)
    }, 400)
  }, [lastItem, rows.length, isLoading, hasMore])
 
  const totalSize = useMemo(() => v.getTotalSize(), [v, rows.length, hasMore])
 
  return (
    <div ref={parentRef} style={{ height: 520, overflow: 'auto', border: '1px solid #ddd' }}>
      <div style={{ height: totalSize, position: 'relative' }}>
        {virtualItems.map((item) => {
          const isLoader = hasMore && item.index === rows.length
          const label = isLoader ? (isLoading ? 'Loading…' : 'Load more…') : rows[item.index].label
 
          return (
            <div
              key={isLoader ? 'loader' : rows[item.index].id}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: item.size,
                transform: `translateY(${item.start}px)`,
                padding: '0 12px',
                boxSizing: 'border-box',
                display: 'flex',
                alignItems: 'center',
                borderBottom: '1px solid #f0f0f0',
                color: isLoader ? '#666' : '#111',
              }}
            >
              {label}
            </div>
          )
        })}
      </div>
    </div>
  )
}

Što mjeriti:

  • Vrijeme appendanja stranice treba ostati otprilike konstantno kako broj ukupnih redaka raste.
  • Skrolanje ne smije pokazivati prazne rupe kada podaci stignu.
  • React commitovi trebaju ostati mali tijekom tipičnih scroll interakcija.

⚠️ Upozorenje: Nemojte koristiti index u arrayu kao React key za stvarne podatke. Kada ubacujete, filtrirate ili osvježavate stranice, index keyevi uzrokuju remountanje i gubitak stanja, što je posebno vidljivo u virtualiziranim UI-jevima.

# Korak 4: Sticky zaglavlja koja ostaju poravnata#

Sticky zaglavlja su česta u tablicama, gridovima i grupiranim listama. S virtualizacijom obično želite:

  • Zaglavlje koje nije virtualizirano (uvijek u DOM-u)
  • Scroll container u kojem je samo body virtualiziran
  • Opcionalno: sticky group zaglavlja unutar liste

Obrazac sticky zaglavlja tablice#

Koristite wrapper s fiksnim zaglavljem i scrollabilnim bodyjem. Zaglavlje se ne miče, body je virtualiziran.

SlojPozicioniranjeVirtualizirano
Red zaglavljasticky ili odvojeni containerNe
Scroll containeroverflow autoN/A
Redoviapsolutno pozicionirani unutar spaceraDa

Pristup implementaciji:

  1. 1
    Renderirajte zaglavlje izvan virtualiziranog bodyja.
  2. 2
    Osigurajte da širine stupaca u zaglavlju odgovaraju stupcima u bodyju.
  3. 3
    Ako koristite CSS grid ili fiksne širine, držite ih u jednom zajedničkom config objektu.

Ako trebate kompletno rješenje za tablice sa sortiranjem, pinningom i virtualizacijom, krenite od virtualizacija React tablica i beskonačni scroll s TanStack Table.

ℹ️ Napomena: Sticky elementi unutar transformiranog parenta mogu se ponašati neočekivano. Ako koristite transform na ancestor elementima sticky zaglavlja, preglednik može drugačije tretirati sticky container. Držite sticky zaglavlja izvan transformiranih containera ili koristite odvojeni wrapper za zaglavlje.

# Korak 5: Dinamičke visine redaka (teži dio)#

Stvarne liste rijetko imaju savršeno fiksne visine. Komentari se šire, badgevi se lome u novi red, pojavljuju se error stanja, a fontovi se učitaju kasno. TanStack Virtual podržava mjerenje, ali to morate tretirati kao sustav kojem trebaju stabilna ograničenja.

Najbolje prakse za dinamičke visine#

ProblemSimptomRješenje
Visina se mijenja nakon renderiranjaPreklapanja, skrol “skače”Izmjerite element, ponovno izmjerite kad se sadržaj promijeni
Responsive layoutPogrešne procjene nakon resizeaPozovite virtualizer.measure() na resize
Kasno učitani fontovi ili slikeLayout shift tijekom skrolanjaPostavite eksplicitne dimenzije slika, rezervirajte prostor, ponovno izmjerite nakon loada
Miješane visineLoša preciznost skrolaKoristite razuman estimateSize i mjerenje

Praktičan pristup:

  1. 1
    Držite dobar estimateSize (blizu mediane visine reda).
  2. 2
    Mjerite stvarne row elemente.
  3. 3
    Izbjegavajte skup layout unutar svakog reda.

Ako se sadržaj reda promijeni nakon async updatea, ponovno izmjerite. Radite to namjerno, ne na svaki render.

TSX
import React, { useEffect, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
 
export function VirtualizedDynamicRows() {
  const parentRef = useRef<HTMLDivElement | null>(null)
  const [rows] = useState(() =>
    Array.from({ length: 3000 }, (_, i) => ({
      id: String(i),
      text: i % 7 === 0 ? 'A longer row that will likely wrap on smaller widths.' : 'Short row.',
    }))
  )
 
  const v = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 52,
    overscan: 10,
  })
 
  useEffect(() => {
    const onResize = () => v.measure()
    window.addEventListener('resize', onResize)
    return () => window.removeEventListener('resize', onResize)
  }, [v])
 
  return (
    <div ref={parentRef} style={{ height: 560, overflow: 'auto', border: '1px solid #ddd' }}>
      <div style={{ height: v.getTotalSize(), position: 'relative' }}>
        {v.getVirtualItems().map((item) => (
          <div
            key={rows[item.index].id}
            ref={v.measureElement}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${item.start}px)`,
              padding: 12,
              boxSizing: 'border-box',
              borderBottom: '1px solid #f0f0f0',
              lineHeight: 1.3,
            }}
          >
            <strong>#{rows[item.index].id}</strong> — {rows[item.index].text}
          </div>
        ))}
      </div>
    </div>
  )
}

Ovaj obrazac mijenja dio CPU-a za korektnost. Što imate manje dinamičkih promjena, to će se sve osjećati bolje.

# Profiliranje: dokažite dobitak u performansama (ne samo “djeluje brže”)#

Virtualizacija treba biti mjerljiva. Cilj je smanjiti:

  • Broj montiranih DOM čvorova
  • Trajanje React commitova tijekom skrolanja
  • Long taskove na main threadu

Što snimiti prije i poslije#

MetrikaAlatCilj
Broj DOM čvorovaChrome DevTools, Elements panelSmanjenje za 90 posto ili više za velike liste
React commit timeReact DevTools ProfilerManji commitovi tijekom skrolanja, manje rerenderiranja
Long taskoviChrome Performance panelManje taskova preko 50 ms tijekom skrolanja
MemorijaChrome Performance ili Task ManagerManji heap, manje “detached” čvorova

Preporučeni workflow:

  1. 1
    Snimite baseline s nevirtualiziranom listom koristeći istu row komponentu.
  2. 2
    Snimite virtualiziranu verziju s istom veličinom dataseta.
  3. 3
    Skrolajte konzistentnom brzinom i usporedite React commitove i long taskove.

Za React-specifične obrasce profiliranja i kako interpretirati commit grafove, koristite profiliranje React performansi, memoizacija, obrasci renderiranja.

Praktični savjeti za profiliranje#

  • Profilirajte u throttled CPU modu (npr. 4x usporenje) da biste otkrili probleme koje stvarni korisnici na mid-range uređajima osjećaju.
  • Za beskonačne liste mjerite “scroll dok se učitava nova stranica”, ne samo idle scroll.
  • Pazite na rerenderiranja koja se mogu izbjeći: row komponente ne bi se trebale rerenderirati kada se promijeni nepovezano stanje.

Ako vidite commitove koje pokreće skrolanje, provjerite da ne spremate scroll position u React state. Pustite preglednik da upravlja skrolom, a virtualizer neka izvede što je vidljivo.

# Checklist za pristupačnost i UX (specifično za virtualizaciju)#

Virtualizacija mijenja DOM dok korisnik skrola. To utječe na navigaciju tipkovnicom, screen readere i očekivanja poput “find in page”.

Tipkovnica i fokus#

  1. 1
    Osigurajte da fokus ne završi na itemima koji će se odmah unmountati.
  2. 2
    Ako redovi sadrže inpute, sačuvajte stanje izvan row komponente (spremite po id-u).
  3. 3
    Osigurajte jasne focus stilove i da tab order ostane predvidljiv.

Screen readeri i semantika#

  • Preferirajte semantičke containere gdje god možete, ali nemojte forsirati table semantiku ako ne možete održati ispravne odnose.
  • Dodajte aria-label na scroll region kako bi bio otkriven.
  • Najavite loading stanja za beskonačne liste s aria-live regionom ako je učitavanje automatsko.

“Find in page” i selekcija#

Browser find vidi samo montirani DOM. Za aplikacije s puno podataka, ponudite eksplicitan UI za pretragu i filtrirajte dataset, umjesto oslanjanja na nativni find.

⚠️ Upozorenje: Virtualizirane tablice koje se pretvaraju da su prave HTML tablice često narušavaju očekivanja assistive tehnologija. Ako trebate pravu semantiku tablice radi usklađenosti s pristupačnošću, razmislite o paginaciji ili hibridnom pristupu gdje ograničite broj redaka, ali zadržite pravu strukturu tablice.

# Checklist rubnih slučajeva (spremnost za produkciju)#

Koristite ovo kao checklistu prije lansiranja za bilo koju implementaciju “React virtualization TanStack Virtual”.

Rubni slučajRizikUblažavanje
Dinamičke visine redakaPreklapanja i skokovi skrolaKoristite measureElement, dobar estimateSize, ponovno mjerite na resize
Slike se učitavaju kasnoLayout shiftPostavite width i height, rezervirajte prostor, po potrebi ponovno mjerite nakon loada
Filtriranje i sortiranjeSkok na pogrešan scroll offsetResetirajte scroll na vrh, stabilni keyevi, izbjegavajte index keyeve
Dodavanje novih stranicaTrzanje skrola tijekom fetchaPrefetch ranije, zadržite overscan, renderirajte loader red
Varijabilna veličina containeraNeispravna mjerenjaPozovite measure() na resize i promjene layouta
Poravnanje sticky zaglavljaNeusklađeni stupciZajednički config širina stupaca, zaglavlje izvan transformiranog containera
Server renderingHydration mismatchRenderirajte ograničen početni window, izbjegavajte čitanje scroll positiona na prvom paintu
Touch uređajiPrazne rupe pri brzom “flingu”Povećajte overscan i prefetch prag

Ako optimizirate cijelu stranicu, a ne samo listu, uskladite virtualizaciju sa širim poboljšanjima poput optimizacije slika, cacheiranja i smanjenja bundle sizea. Koristite optimizacija performansi web stranice kako ne biste popravili jedno usko grlo, a ignorirali veća.

# Ključne poruke#

  • Krenite prvo s virtualizacijom fiksne veličine, jer je to najbrža i najjednostavnija baza za isporučiti.
  • Podesite overscan prema složenosti reda i input uređajima, zatim validirajte s React Profilerom i Chrome Performance.
  • Implementirajte beskonačno učitavanje pomoću loader reda i prefetch praga tako da skrol nikad ne naleti na praznu rupu.
  • Dinamičke visine redaka tretirajte kao rubni slučaj prve klase: mjerite elemente, ponovno mjerite na resize i rezervirajte prostor za sadržaj koji se učitava kasno.
  • Pristupačnost planirajte eksplicitno: upravljajte fokusom, ponudite UI za pretragu umjesto oslanjanja na browser find i izbjegavajte “fake” table semantiku ako trebate usklađenost.

# Zaključak#

TanStack Virtual je jedan od najpraktičnijih načina da velike React liste i gridovi djeluju trenutno: manje DOM čvorova, manji React commitovi i glađe skrolanje pod stvarnim opterećenjem. Prava pobjeda dolazi kada kombinirate windowing, beskonačno učitavanje i disciplinirano profiliranje kako biste poboljšanje dokazali, a ne pogađali.

Ako želite da pregledamo vašu trenutnu implementaciju liste ili tablice, profiliramo je i isporučimo produkcijski spremnu virtualizaciju sa sticky zaglavljima i beskonačnim učitavanjem, kontaktirajte Samioda putem naše stranice za React i Next.js usluge i uključite snimku ekrana (screen recording) plus veličinu trenutnog dataseta i složenost redaka.

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.