Yasin Ateş

Virtualization: Binlerce Satırı Sorunsuz Render Etmenin Yolu (TanStack ve React Örnekleriyle) ⚡️

Virtualization: Binlerce Satırı Sorunsuz Render Etmenin Yolu (TanStack ve React Örnekleriyle) ⚡️

10.000 satırlık bir liste render etmeniz gerektiğinde tarayıcı neden donuyor? Cevap basit: virtualization. Bu yazıda, React uygulamalarında büyük veri setlerini akıcı şekilde render etmek için kullanılan virtualization tekniğini, örneklerle ele alacağız.

Diyelim ki içerisinde liste olan bir uygulama geliştiriyoruz ve 15.000 liste elemanı var (Örn: Instagram veya Twitter’daki gibi postların listelendiği timeline olan bir uygulama ya da çok fazla ürünnün listelendiği bir e-ticaret sitesi vb). Kullanıcı arama yaptığında tüm bu item’ları bir liste halinde göstermek istiyoruz.

İlk akla gelen çözüm şu:

// ❌ Bu yaklaşım performans cehennemi
function Timeline({ tweets }) {
 return (
 <ul>
 {tweets.map((tweet) => (
 <li key={tweet.id}>
 <TweetCard tweet={tweet} />
 </li>
 ))}
 </ul>
 );
}

Burada ne oluyor? 15.000 DOM node’u aynı anda yaratılıyor. Her biri memory’de yer kaplıyor, her state değişikliğinde reconciliation sürecine giriyor ve tarayıcı layout hesaplamalarını tüm bu node’lar için tekrar yapıyor.

Chrome DevTools’u açtığımızda şunu görürüz: sayfa ilk yüklendiğinde bile yüzlerce milisaniye harcıyor. Scroll etmeye başladığımızda frame rate 60fps’den 10–15fps’e düşüyor. Kullanıcı deneyimi açısından bu, uygulamanın kullanılamaz hale geldiği anlamına geliyor.

🔍 Virtualization Nasıl Çalışır?

Virtualization çalışma mantığı

Virtualization (veya diğer adıyla windowing), yalnızca görünür öğelerin render edilmesi prensibine dayanır.

Virtualization’ın temel fikri: kullanıcı neyi görebiliyorsa yalnızca onu render et.

Viewport’ta 10 item görünüyorsa, DOM’da yalnızca 10–15 node olsun. Kullanıcı scroll ettikçe görünen node’ların içeriği güncelleniyor; yeni node’lar oluşturulmuyor. Liste container’ının toplam yüksekliği hesaplanıyor ve bu yükseklik korunuyor, böylece scroll bar doğru orantıda çalışıyor.

Bu yaklaşımda DOM node sayısı sabit kalıyor. 15.000 item olsa da DOM’da en fazla 20–30 node görünüyor.

📦 TanStack Virtual

TanStack Virtual, headless bir virtualization kütüphanesidir.

📝 Benim uzun süredir bildiğim react-virtualized kütüphanesi var. Ancak şuan en güncel olan TanStack’in kütüphaneleri gibi görünüyor, bu yüzden ben makaleyi onun üzerinden hazırladım.

Kurulumla başlayalım:

npm install @tanstack/react-virtual

Basit Bir Virtual Liste Oluşturalım

İlk örneğimizde sabit yükseklikli satırlardan oluşan bir liste yapalım.

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

function VirtualList({ items }) {
 // Scroll container'a ihtiyacımız var
 const parentRef = useRef(null);

 const rowVirtualizer = useVirtualizer({
 count: items.length, // Toplam öğe sayısı
 getScrollElement: () => parentRef.current,
 estimateSize: () => 64, // Her satırın tahmini yüksekliği (px)
 overscan: 5, // Viewport dışında extra render edilen öğe sayısı
 });

 return (
 // Scroll edilebilir container
 <div
 ref={parentRef}
 style={{
 height: "600px",
 overflow: "auto",
 }}
 >
 {/* Toplam liste yüksekliği — scroll bar için gerekli */}
 <div
 style={{
 height: `${rowVirtualizer.getTotalSize()}px`,
 width: "100%",
 position: "relative",
 }}
 >
 {rowVirtualizer.getVirtualItems().map((virtualItem) => (
 <div
 key={virtualItem.key}
 style={{
 position: "absolute",
 top: 0,
 left: 0,
 width: "100%",
 height: `${virtualItem.size}px`,
 transform: `translateY(${virtualItem.start}px)`,
 }}
 >
 {items[virtualItem.index].name}
 </div>
 ))}
 </div>
 </div>
 );
}
  • count: Toplam kaç öğe olduğunu söylüyoruz; hepsini render etmiyoruz, sadece sayısını bildiriyoruz.
  • estimateSize: Başlangıçta her öğenin kaç pixel yükseklikte olduğunu tahmin ediyoruz.
  • overscan: Viewport'un hemen dışında kaç öğenin "hazır" tutulacağını belirliyor; scroll sırasında flash (beyaz alanın görünmesi) önleniyor.
  • getTotalSize(): Tüm liste render edilseydi kaç pixel yükseklikte olurdu? Bu değeri container'a veriyoruz ki scroll bar doğru çalışsın.
  • transform: translateY(...): Her öğeyi absolute pozisyonla doğru konuma taşıyoruz.

📐 Dinamik Yükseklikli Öğeler

Her satır aynı yükseklikte olmayabilir. Açıklaması uzun ürünler, farklı boyutlu resimler, expand/collapse içeriği olan kartlar vs.

TanStack Virtual bunu measureElement callback'i ile çözüyor.

import { useRef, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

function DynamicVirtualList({ items }) {
 const parentRef = useRef(null);

 const rowVirtualizer = useVirtualizer({
 count: items.length,
 getScrollElement: () => parentRef.current,
 estimateSize: () => 100, // Başlangıç tahmini
 measureElement:
 typeof window !== "undefined" &&
 navigator.userAgent.indexOf("Firefox") === -1
 ? (element) => element?.getBoundingClientRect().height
 : undefined,
 });

 return (
 <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
 <div
 style={{
 height: `${rowVirtualizer.getTotalSize()}px`,
 width: "100%",
 position: "relative",
 }}
 >
 {rowVirtualizer.getVirtualItems().map((virtualItem) => (
 <div
 key={virtualItem.key}
 // measureElement'in çalışması için data-index gerekli
 data-index={virtualItem.index}
 // ref ile gerçek DOM yüksekliği ölçülüyor
 ref={rowVirtualizer.measureElement}
 style={{
 position: "absolute",
 top: 0,
 left: 0,
 width: "100%",
 transform: `translateY(${virtualItem.start}px)`,
 }}
 >
 <ProductCard product={items[virtualItem.index]} />
 </div>
 ))}
 </div>
 </div>
 );
}

Buradaki kritik fark şu:

  • ref={rowVirtualizer.measureElement}: Her öğe DOM'a mount olduğunda gerçek yüksekliği ölçülüyor ve virtualizer bu bilgiyle tüm hesaplamalarını güncelliyor.
  • data-index={virtualItem.index}: Virtualizer hangi öğeyi ölçtüğünü bu attribute üzerinden anlıyor.
  • Firefox kontrolü: Firefox’ta getBoundingClientRect() bazı edge case'lerde farklı sonuçlar verebiliyor; bu yüzden özellikle dışarıda tutuluyor.

🔲 Grid Virtualization

Bazen liste değil, grid yapısına ihtiyacımız oluyor. Fotoğraf galerisi, ürün kartılarından oluşan gridler gibi senaryolarda hem yatay hem dikey eksende virtualization gerekiyor.

Grid Virtualization

TanStack Virtual’da grid için hem satır hem sütun virtualizer’larını birlikte kullanıyoruz:

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

const COLUMN_COUNT = 4;
const CARD_HEIGHT = 280;
const CARD_WIDTH = 250;

function VirtualGrid({ items }) {
 const parentRef = useRef(null);

 // Satır sayısını sütun sayısına göre hesaplıyoruz
 const rowCount = Math.ceil(items.length / COLUMN_COUNT);

 const rowVirtualizer = useVirtualizer({
 count: rowCount,
 getScrollElement: () => parentRef.current,
 estimateSize: () => CARD_HEIGHT,
 overscan: 2,
 });

 const columnVirtualizer = useVirtualizer({
 horizontal: true, // Yatay virtualization aktif
 count: COLUMN_COUNT,
 getScrollElement: () => parentRef.current,
 estimateSize: () => CARD_WIDTH,
 overscan: 1,
 });

 return (
 <div
 ref={parentRef}
 style={{
 height: "700px",
 overflow: "auto",
 }}
 >
 <div
 style={{
 height: `${rowVirtualizer.getTotalSize()}px`,
 width: `${columnVirtualizer.getTotalSize()}px`,
 position: "relative",
 }}
 >
 {rowVirtualizer.getVirtualItems().map((virtualRow) =>
 columnVirtualizer.getVirtualItems().map((virtualColumn) => {
 // Hangi öğeye denk geldiğini hesaplayalım
 const itemIndex = virtualRow.index * COLUMN_COUNT + virtualColumn.index;

 // Öğe yoksa boş döndür (son satırda eksik kart olabilir)
 if (itemIndex >= items.length) return null;

 return (
 <div
 key={`${virtualRow.index}-${virtualColumn.index}`}
 style={{
 position: "absolute",
 top: 0,
 left: 0,
 width: `${virtualColumn.size}px`,
 height: `${virtualRow.size}px`,
 transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`,
 }}
 >
 <ProductCard product={items[itemIndex]} />
 </div>
 );
 })
 )}
 </div>
 </div>
 );
}
  • horizontal: true: Column virtualizer'a yatay eksende çalışması gerektiğini söylüyoruz.
  • İki virtualizer’ı iç içe map ederek 2D koordinat sistemi kuruyoruz.
  • translateX + translateY ile her kartı doğru konuma taşıyoruz.
  • Son satırda eksik kart olabileceği için index sınırı kontrol ediyoruz.

♾️ Infinite Scroll ile Virtualization

En sık karşılaştığımız senaryo, sayfalama yerine infinite scroll ama aynı zamanda performanslı bir liste. Bu ikisini birleştirmek için TanStack Query + TanStack Virtual ikilisini kullanıyoruz.

import { useRef, useEffect, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useInfiniteQuery } from "@tanstack/react-query";

const PAGE_SIZE = 20;

async function fetchProducts({ pageParam = 0 }) {
 const response = await fetch(
 `/api/products?offset=${pageParam}&limit=${PAGE_SIZE}`
 );
 return response.json();
}

function InfiniteVirtualList() {
 const parentRef = useRef(null);

 const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
 useInfiniteQuery({
 queryKey: ["products"],
 queryFn: fetchProducts,
 getNextPageParam: (lastPage, allPages) =>
 lastPage.hasMore ? allPages.length * PAGE_SIZE : undefined,
 });

 // Tüm sayfaları tek bir array'de birleştiriyoruz
 const allItems = data?.pages.flatMap((page) => page.items) ?? [];

 // Sonunda bir "loading" satırı için +1
 const totalCount = hasNextPage ? allItems.length + 1 : allItems.length;

 const rowVirtualizer = useVirtualizer({
 count: totalCount,
 getScrollElement: () => parentRef.current,
 estimateSize: () => 80,
 overscan: 5,
 });

 const virtualItems = rowVirtualizer.getVirtualItems();

 // Son öğeye yaklaştığımızda yeni sayfa yükle
 useEffect(() => {
 const lastItem = virtualItems[virtualItems.length - 1];

 if (!lastItem) return;

 if (
 lastItem.index >= allItems.length - 1 &&
 hasNextPage &&
 !isFetchingNextPage
 ) {
 fetchNextPage();
 }
 }, [virtualItems, allItems.length, hasNextPage, isFetchingNextPage, fetchNextPage]);

 return (
 <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
 <div
 style={{
 height: `${rowVirtualizer.getTotalSize()}px`,
 width: "100%",
 position: "relative",
 }}
 >
 {virtualItems.map((virtualItem) => {
 const isLoaderRow = virtualItem.index > allItems.length - 1;

 return (
 <div
 key={virtualItem.key}
 style={{
 position: "absolute",
 top: 0,
 left: 0,
 width: "100%",
 height: `${virtualItem.size}px`,
 transform: `translateY(${virtualItem.start}px)`,
 }}
 >
 {isLoaderRow ? (
 <div className="loading-spinner">Yükleniyor...</div>
 ) : (
 <ProductRow product={allItems[virtualItem.index]} />
 )}
 </div>
 );
 })}
 </div>
 </div>
 );
}

Bu pattern’de dikkat etmemiz gereken kısımlar şunlar:

  • flatMap: Her sayfa ayrı bir array olarak geliyor, hepsini birleştirerek tek bir flat liste elde ediyoruz.
  • totalCount + 1: Eğer daha sayfa varsa, listenin sonuna bir "ghost" satır ekliyoruz. Bu satır loading spinner'ı gösteriyor.
  • useEffect içindeki intersection kontrolü: Son virtual item'ın index'i gerçek öğe sayısına yaklaştığında fetchNextPage() tetikleniyor. Kullanıcı scroll'u bitirmeden yeni veri hazır oluyor.

🔄 Belirli Bir Öğeye Scroll Etmek

“Sepete eklendi, ürüne git” veya “arama sonucuna atla” gibi senaryolarda belirli bir öğeye programatik olarak scroll etmemiz gerekiyor.

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

function ScrollableList({ items, highlightedIndex }) {
 const parentRef = useRef(null);

 const rowVirtualizer = useVirtualizer({
 count: items.length,
 getScrollElement: () => parentRef.current,
 estimateSize: () => 64,
 });

 // Belirli bir öğeye scroll etmek için
 const scrollToItem = (index) => {
 rowVirtualizer.scrollToIndex(index, {
 align: "start", // "start" | "center" | "end" | "auto"
 behavior: "smooth",
 });
 };

 return (
 <div>
 <button onClick={() => scrollToItem(highlightedIndex)}>
 Seçili ürüne git
 </button>

 <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
 <div
 style={{
 height: `${rowVirtualizer.getTotalSize()}px`,
 position: "relative",
 }}
 >
 {rowVirtualizer.getVirtualItems().map((virtualItem) => (
 <div
 key={virtualItem.key}
 style={{
 position: "absolute",
 top: 0,
 left: 0,
 width: "100%",
 height: `${virtualItem.size}px`,
 transform: `translateY(${virtualItem.start}px)`,
 backgroundColor:
 virtualItem.index === highlightedIndex
 ? "#1a1a2e"
 : "transparent",
 }}
 >
 {items[virtualItem.index].name}
 </div>
 ))}
 </div>
 </div>
 </div>
 );
}

scrollToIndex metodunun parametreleri:

  • align: "start": Öğeyi viewport'un üst kenarına hizala.
  • align: "center": Öğeyi tam ortaya getir; highlight senaryoları için ideal.
  • align: "auto": Öğe zaten görünüyorsa scroll etme, değilse minimum scroll yap.
  • behavior: "smooth": Native smooth scroll davranışı; kullanıcı nereye gittiğini anlayabilsin.

❓ Ne Zaman Virtualization Kullanılmalı?

Virtualization özellikle büyük veri setleriyle çalışırken ciddi performans kazancı sağlar. Ancak her liste için gerekli değildir. Yanlış yerde kullanıldığında gereksiz karmaşıklık oluşturabilir.

Genellikle şu senaryolarda virtualization tercih edilir:

  • Binlerce öğeden oluşan listeler
  • Infinite scroll kullanılan timeline yapıları
  • Büyük ürün katalogları
  • Admin paneli tabloları
  • Veri yoğun dashboard ekranları
  • Fotoğraf galerileri ve grid yapıları

Özellikle aynı anda yüzlerce veya binlerce DOM node’un render edildiği durumlarda virtualization ciddi fark yaratır.

⚠️ Ne Zaman Virtualization Kullanılmamalı?

Bazı durumlarda virtualization gereksiz olabilir:

  • 50–100 öğelik küçük listeler
  • SEO açısından önemli içerikler
  • Çok basit ve statik tablolar
💡 Unutmayalım ki, virtualization’ın da bir maliyeti vardır. Ek hesaplamalar, ölçümler ve scroll yönetimi nedeniyle kod karmaşıklığını artırabilir. Bu yüzden gerçekten ihtiyaç olan yerlerde kullanmak daha doğru bir yaklaşım olacaktır.

🏁 Sonuç

Virtualization, büyük veri setleriyle çalışan her projede uygulayabileceğimiz temel bir pattern. DOM node sayısını sabit tutarak bellek kullanımını dramatik şekilde azaltıyor.

Özetlemek gerekirse:

★ Basit liste ihtiyaçları için useVirtualizer + sabit estimateSize yeterli.

★ Dinamik yükseklikli içerikler için measureElement callback'ini aktif ediyoruz.

★ Grid yapıları için hem satır hem sütun virtualizer'larını birleştiriyoruz.

★ Infinite scroll için TanStack Query ile entegrasyon temiz bir çözüm sunuyor.

📬 Geri Bildirim

Makaleyi yazarken, kaynakları belirleme, araştırma, yazım denetimi ve araştırma için Claude Opus 4.7 ve GPT 5.4 High modellerini kullandım. Resimleri üretmek için ise Gemini 3.1 Flash Image Preview (Nano Banana 2) ve Gemini 3 Pro Preview 2k (Nano Banana Pro) modellerini kullandım.

Yazı ile ilgili tavsiye, öneri, eleştirileri dikkate alıyorum. İletişime geçmek isterseniz bana websitemdeki sosyal medya adreslerimden veya Linkedin üzerinden ulaşabilirsiniz.

Sevgiyle kalın, Yasin 🤗

📚 Makaleyi Yazarken Kullandığım Kaynaklar