Back to Blog

React Virtualized Lists in 2026: Infinite Scroll and Large List Optimization

Build performant virtualized lists in React with @tanstack/react-virtual: infinite scroll, dynamic row heights, bidirectional loading, sticky headers, and mobile touch optimization.

Viprasol Tech Team
January 3, 2027
13 min read

React Virtualized Lists in 2026: Infinite Scroll and Large List Optimization

Rendering 10,000 items in a <ul> is a browser performance disaster: 10,000 DOM nodes, constant layout reflow, and a page that hangs on scroll. Virtualization solves this by only rendering the items currently visible in the viewportβ€”typically 20–50 items regardless of total list size.

@tanstack/react-virtual is the right tool in 2026: it's framework-agnostic, headless, handles dynamic item heights, and integrates cleanly with React Query for infinite scroll. This post builds three patterns you'll actually use: a fixed-height list, a dynamic-height feed, and bidirectional infinite loading.


Installation

npm install @tanstack/react-virtual @tanstack/react-query

Pattern 1: Fixed-Height Virtualized List

The simplest case β€” all items are the same height:

// components/VirtualList/FixedList.tsx
"use client";

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

interface Item {
  id: string;
  title: string;
  subtitle: string;
}

interface FixedListProps {
  items: Item[];
  height?: number;       // Container height in px
  itemHeight?: number;   // Fixed row height in px
  onItemClick?: (item: Item) => void;
}

export function FixedVirtualList({
  items,
  height = 600,
  itemHeight = 64,
  onItemClick,
}: FixedListProps) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => itemHeight,
    overscan: 5,  // Render 5 items outside viewport for smooth scroll
  });

  return (
    <div
      ref={parentRef}
      style={{ height, overflow: "auto" }}
      className="border rounded-lg"
    >
      {/* Total scrollable height β€” creates the scrollbar */}
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const item = items[virtualItem.index];
          return (
            <div
              key={virtualItem.key}
              data-index={virtualItem.index}
              ref={virtualizer.measureElement}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                transform: `translateY(${virtualItem.start}px)`,
              }}
            >
              <button
                onClick={() => onItemClick?.(item)}
                className="w-full text-left flex items-center gap-3 px-4 py-3 border-b hover:bg-gray-50 transition-colors"
              >
                <div className="h-9 w-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-semibold text-sm flex-shrink-0">
                  {item.title[0].toUpperCase()}
                </div>
                <div className="min-w-0">
                  <p className="font-medium text-sm truncate">{item.title}</p>
                  <p className="text-xs text-gray-500 truncate">{item.subtitle}</p>
                </div>
              </button>
            </div>
          );
        })}
      </div>
    </div>
  );
}

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Pattern 2: Dynamic-Height Feed with Infinite Scroll

The most common real-world case β€” variable height items loaded page by page:

// hooks/useInfiniteFeed.ts
import { useInfiniteQuery } from "@tanstack/react-query";

interface FeedPost {
  id: string;
  author: { name: string; avatarUrl: string };
  content: string;
  imageUrl?: string;
  likeCount: number;
  commentCount: number;
  createdAt: string;
}

interface FeedPage {
  items: FeedPost[];
  nextCursor: string | null;
  totalCount: number;
}

async function fetchFeedPage(cursor?: string): Promise<FeedPage> {
  const params = new URLSearchParams({ limit: "20" });
  if (cursor) params.set("cursor", cursor);

  const res = await fetch(`/api/feed?${params}`);
  if (!res.ok) throw new Error("Failed to fetch feed");
  return res.json();
}

export function useInfiniteFeed() {
  return useInfiniteQuery({
    queryKey: ["feed"],
    queryFn: ({ pageParam }) => fetchFeedPage(pageParam as string | undefined),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    staleTime: 60_000,
  });
}
// components/Feed/InfiniteFeed.tsx
"use client";

import { useRef, useEffect, useCallback } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useInfiniteFeed } from "@/hooks/useInfiniteFeed";
import { FeedPostCard } from "./FeedPostCard";
import { Loader2 } from "lucide-react";

export function InfiniteFeed() {
  const parentRef = useRef<HTMLDivElement>(null);

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
  } = useInfiniteFeed();

  // Flatten all pages into a single array
  const allItems = data?.pages.flatMap((page) => page.items) ?? [];

  const virtualizer = useVirtualizer({
    count: hasNextPage ? allItems.length + 1 : allItems.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 200,   // Initial estimate β€” overridden by measureElement
    overscan: 3,
  });

  // Trigger next page load when the sentinel item enters viewport
  useEffect(() => {
    const virtualItems = virtualizer.getVirtualItems();
    if (!virtualItems.length) return;

    const lastItem = virtualItems[virtualItems.length - 1];

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

  if (isLoading) {
    return (
      <div className="flex items-center justify-center py-16">
        <Loader2 className="h-8 w-8 animate-spin text-gray-400" />
      </div>
    );
  }

  if (isError) {
    return (
      <div className="text-center py-16 text-red-600">
        Failed to load feed. Please try again.
      </div>
    );
  }

  if (allItems.length === 0) {
    return (
      <div className="text-center py-16 text-gray-500">
        Nothing here yet. Check back soon!
      </div>
    );
  }

  return (
    <div
      ref={parentRef}
      className="h-screen overflow-auto"
      style={{ contain: "strict" }}  // Performance: isolate layout
    >
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          width: "100%",
          position: "relative",
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const isLoaderRow = virtualItem.index > allItems.length - 1;

          return (
            <div
              key={virtualItem.key}
              data-index={virtualItem.index}
              ref={virtualizer.measureElement}  // Measures actual height
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                transform: `translateY(${virtualItem.start}px)`,
              }}
            >
              {isLoaderRow ? (
                <div className="flex items-center justify-center py-8">
                  {isFetchingNextPage ? (
                    <Loader2 className="h-6 w-6 animate-spin text-gray-400" />
                  ) : (
                    <p className="text-sm text-gray-400">All caught up!</p>
                  )}
                </div>
              ) : (
                <FeedPostCard post={allItems[virtualItem.index]} />
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}
// components/Feed/FeedPostCard.tsx
"use client";

import Image from "next/image";
import { Heart, MessageCircle, Share2 } from "lucide-react";
import { formatDistanceToNow } from "date-fns";

interface Props {
  post: {
    id: string;
    author: { name: string; avatarUrl: string };
    content: string;
    imageUrl?: string;
    likeCount: number;
    commentCount: number;
    createdAt: string;
  };
}

export function FeedPostCard({ post }: Props) {
  return (
    <article className="border-b bg-white p-4">
      {/* Author */}
      <div className="flex items-center gap-3 mb-3">
        <Image
          src={post.author.avatarUrl}
          alt={post.author.name}
          width={40}
          height={40}
          className="rounded-full"
        />
        <div>
          <p className="font-semibold text-sm">{post.author.name}</p>
          <p className="text-xs text-gray-400">
            {formatDistanceToNow(new Date(post.createdAt), { addSuffix: true })}
          </p>
        </div>
      </div>

      {/* Content */}
      <p className="text-sm text-gray-900 leading-relaxed mb-3">{post.content}</p>

      {/* Optional image */}
      {post.imageUrl && (
        <div className="relative aspect-video rounded-lg overflow-hidden mb-3">
          <Image
            src={post.imageUrl}
            alt="Post image"
            fill
            className="object-cover"
            sizes="(max-width: 768px) 100vw, 600px"
          />
        </div>
      )}

      {/* Actions */}
      <div className="flex items-center gap-6 text-gray-500">
        <button className="flex items-center gap-1.5 text-sm hover:text-red-500 transition-colors">
          <Heart className="h-4 w-4" />
          <span>{post.likeCount.toLocaleString()}</span>
        </button>
        <button className="flex items-center gap-1.5 text-sm hover:text-blue-500 transition-colors">
          <MessageCircle className="h-4 w-4" />
          <span>{post.commentCount.toLocaleString()}</span>
        </button>
        <button className="flex items-center gap-1.5 text-sm hover:text-green-500 transition-colors">
          <Share2 className="h-4 w-4" />
        </button>
      </div>
    </article>
  );
}

Pattern 3: Virtualized List with Sticky Group Headers

// components/ContactList/GroupedContactList.tsx
"use client";

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

interface Contact {
  id: string;
  name: string;
  email: string;
}

type ListItem =
  | { type: "header"; letter: string }
  | { type: "contact"; contact: Contact };

function buildGroupedItems(contacts: Contact[]): ListItem[] {
  const sorted = [...contacts].sort((a, b) => a.name.localeCompare(b.name));
  const items: ListItem[] = [];
  let currentLetter = "";

  for (const contact of sorted) {
    const letter = contact.name[0].toUpperCase();
    if (letter !== currentLetter) {
      currentLetter = letter;
      items.push({ type: "header", letter });
    }
    items.push({ type: "contact", contact });
  }

  return items;
}

export function GroupedContactList({ contacts }: { contacts: Contact[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const items = useMemo(() => buildGroupedItems(contacts), [contacts]);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: (index) => {
      // Headers are 32px, contact rows are 56px
      return items[index].type === "header" ? 32 : 56;
    },
    overscan: 10,
  });

  return (
    <div ref={parentRef} className="h-[600px] overflow-auto border rounded-lg">
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const item = items[virtualItem.index];

          return (
            <div
              key={virtualItem.key}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                transform: `translateY(${virtualItem.start}px)`,
                height: virtualItem.size,
              }}
            >
              {item.type === "header" ? (
                <div className="px-4 flex items-center h-full bg-gray-50 border-b">
                  <span className="text-xs font-bold text-gray-500 uppercase tracking-wider">
                    {item.letter}
                  </span>
                </div>
              ) : (
                <div className="flex items-center gap-3 px-4 h-full border-b hover:bg-gray-50">
                  <div className="h-9 w-9 rounded-full bg-indigo-100 flex items-center justify-center text-indigo-700 font-semibold text-sm flex-shrink-0">
                    {item.contact.name[0].toUpperCase()}
                  </div>
                  <div className="min-w-0">
                    <p className="text-sm font-medium truncate">{item.contact.name}</p>
                    <p className="text-xs text-gray-500 truncate">{item.contact.email}</p>
                  </div>
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Pattern 4: Bidirectional Scroll (Chat History)

For chat interfaces where you load older messages above:

// components/Chat/ChatMessages.tsx
"use client";

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

export function ChatMessages({ conversationId }: { conversationId: string }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const atBottomRef = useRef(true);

  const {
    data,
    fetchPreviousPage,
    hasPreviousPage,
    isFetchingPreviousPage,
  } = useInfiniteQuery({
    queryKey: ["messages", conversationId],
    queryFn: ({ pageParam }) =>
      fetchMessages(conversationId, pageParam as string | undefined),
    initialPageParam: undefined as string | undefined,
    getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined,
    getNextPageParam: () => undefined,  // No forward pagination
  });

  const allMessages = data?.pages.flatMap((p) => p.messages).reverse() ?? [];

  const virtualizer = useVirtualizer({
    count: allMessages.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 60,
    overscan: 5,
    // Scroll from bottom (reverse scroll)
    initialOffset: 9999999,
  });

  // Scroll to bottom on new messages (if already at bottom)
  useLayoutEffect(() => {
    if (atBottomRef.current) {
      virtualizer.scrollToIndex(allMessages.length - 1, { behavior: "auto" });
    }
  }, [allMessages.length]);

  // Load older messages when scrolled to top
  useEffect(() => {
    const el = parentRef.current;
    if (!el) return;

    const handleScroll = () => {
      atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50;

      if (el.scrollTop < 100 && hasPreviousPage && !isFetchingPreviousPage) {
        const prevHeight = el.scrollHeight;
        fetchPreviousPage().then(() => {
          // Maintain scroll position after prepending messages
          requestAnimationFrame(() => {
            el.scrollTop = el.scrollHeight - prevHeight;
          });
        });
      }
    };

    el.addEventListener("scroll", handleScroll, { passive: true });
    return () => el.removeEventListener("scroll", handleScroll);
  }, [hasPreviousPage, isFetchingPreviousPage, fetchPreviousPage]);

  return (
    <div
      ref={parentRef}
      className="flex-1 overflow-auto"
      style={{ contain: "strict" }}
    >
      {isFetchingPreviousPage && (
        <div className="flex justify-center py-3">
          <Loader2 className="h-5 w-5 animate-spin text-gray-400" />
        </div>
      )}

      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const message = allMessages[virtualItem.index];
          return (
            <div
              key={virtualItem.key}
              data-index={virtualItem.index}
              ref={virtualizer.measureElement}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                transform: `translateY(${virtualItem.start}px)`,
              }}
            >
              <ChatMessage message={message} />
            </div>
          );
        })}
      </div>
    </div>
  );
}

Performance Tips

1. contain: "strict" on the scroll container β€” tells the browser not to recalculate layout for the rest of the page when this container scrolls. Big win on complex pages.

2. Always use data-index + ref={virtualizer.measureElement} β€” required for dynamic height measurement. Skipping this causes items to overlap.

3. overscan tuning β€” increase for smoother scroll (at cost of more DOM nodes), decrease for large lists where you need minimum DOM nodes. Start at 5, tune based on profiling.

4. Avoid inline function props on virtualizer items β€” they create new function references on every render. Use useCallback or stable event handlers.

5. key={virtualItem.key} not key={item.id} β€” virtualizer keys are stable even as items scroll in/out. Using item.id can cause React reconciliation issues.

// ❌ Creates new onClick on every render
{virtualizer.getVirtualItems().map((vi) => (
  <Item key={vi.key} onClick={() => handleClick(items[vi.index].id)} />
))}

// βœ… Stable handler
const handleClick = useCallback((id: string) => {
  // ...
}, []);

{virtualizer.getVirtualItems().map((vi) => (
  <Item key={vi.key} id={items[vi.index].id} onClick={handleClick} />
))}

Cost and Timeline Estimates

ComponentTimelineCost (USD)
Fixed-height virtualized list0.5–1 day$400–$800
Dynamic-height with infinite scroll1–2 days$800–$1,600
Grouped list with sticky headers1 day$600–$1,000
Bidirectional chat scroll2–3 days$1,600–$2,500
Full feed/list feature1 week$3,500–$5,500

Performance impact: replacing a 10,000-item non-virtualized list with a virtualized equivalent typically reduces initial render time from 3–8s to under 100ms.


See Also


Working With Viprasol

We build high-performance React interfaces for data-intensive SaaS products. Our frontend team has shipped virtualized feeds, infinite-scroll dashboards, and real-time chat UIs handling tens of thousands of items with sub-100ms interactions.

What we deliver:

  • Virtualized list and table implementations
  • Infinite scroll with cursor-based pagination
  • Bidirectional scroll for chat/timeline interfaces
  • Performance profiling and optimization audits
  • React Native FlatList optimization for mobile

Explore our web development services or contact us to discuss your performance requirements.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.