react-infinite-scroll-component, react-virtuoso, and react-window are all React libraries designed to improve performance when rendering large lists or implementing infinite scroll behavior. They address the common problem of slow rendering, high memory usage, and poor user experience that occurs when trying to display thousands of items at once in the DOM. While they share this goal, their underlying approaches, APIs, and use cases differ significantly. react-window provides low-level virtualization primitives focused on performance and minimalism. react-virtuoso offers a higher-level, feature-rich virtualized list with built-in support for dynamic item sizes, headers, footers, and grouping. react-infinite-scroll-component is not a virtualization library but rather a wrapper that triggers load-more callbacks as the user scrolls near the bottom of a container, typically used alongside pagination strategies.
When building modern web apps, you’ll often face a choice: how do you efficiently render long lists without slowing down the browser? The three libraries — react-infinite-scroll-component, react-virtuoso, and react-window — each offer different strategies. One isn’t universally “better”; the right pick depends on your data, UX needs, and performance constraints.
react-infinite-scroll-component assumes you’re loading data in chunks (e.g., from a paginated API) and just need a way to detect when the user has scrolled near the bottom to fetch the next page. It does not virtualize — it renders every item you give it. So if you load 10 pages of 50 items each, all 500 DOM nodes stay in memory.
// react-infinite-scroll-component: Simple infinite scroll
import InfiniteScroll from 'react-infinite-scroll-component';
function MyList({ items, loadMore, hasMore }) {
return (
<InfiniteScroll
dataLength={items.length}
next={loadMore}
hasMore={hasMore}
loader={<div>Loading...</div>}
>
{items.map(item => <div key={item.id}>{item.name}</div>)}
</InfiniteScroll>
);
}
react-window takes the opposite approach: it only renders what’s visible (plus a small buffer), no matter how large your dataset. But it requires you to know or fix the height of each item ahead of time.
// react-window: Fixed-size list
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>Row {index}</div>
);
function MyList({ itemCount }) {
return (
<List
height={600}
itemCount={itemCount}
itemSize={50}
width="100%"
>
{Row}
</List>
);
}
react-virtuoso sits in the middle: it virtualizes like react-window, but automatically measures item heights and supports dynamic content, headers, and more — all with less boilerplate.
// react-virtuoso: Auto-sizing list
import { Virtuoso } from 'react-virtuoso';
function MyList({ items }) {
return (
<Virtuoso
style={{ height: 600 }}
data={items}
itemContent={(index, item) => <div>{item.name}</div>}
/>
);
}
This is where the libraries diverge sharply.
react-window forces you to choose between two components:
FixedSizeList: all items same height (fastest)VariableSizeList: you must provide an itemSize function that returns the height for each indexIf your content height depends on text length or images, you’ll need to pre-calculate or cache sizes — which adds complexity.
// react-window: Variable size list
import { VariableSizeList as List } from 'react-window';
const getItemSize = (index) => {
// Must return exact pixel height for index
return Math.random() * 50 + 30; // Not realistic — you’d use real logic
};
const Row = ({ index, style }) => (
<div style={style}>Dynamic row {index}</div>
);
function MyList({ itemCount }) {
return (
<List
height={600}
itemCount={itemCount}
itemSize={getItemSize}
width="100%"
>
{Row}
</List>
);
}
react-virtuoso measures items automatically after they render. No need to guess heights — it works out of the box with variable content.
// react-virtuoso: Handles dynamic heights automatically
import { Virtuoso } from 'react-virtuoso';
function MyList({ messages }) {
return (
<Virtuoso
style={{ height: 600 }}
data={messages}
itemContent={(index, message) => (
<div>
<p>{message.text}</p>
<small>{message.timestamp}</small>
</div>
)}
/>
);
}
react-infinite-scroll-component doesn’t care about heights — it renders everything. So dynamic heights are trivial to implement, but performance suffers as the list grows.
What if you want both infinite loading and virtualization? Only react-virtuoso and react-window support this natively.
react-virtuoso makes it easy with the endReached prop:
// react-virtuoso: Infinite scroll + virtualization
import { Virtuoso } from 'react-virtuoso';
function InfiniteVirtuosoList({ items, loadMore, hasMore }) {
return (
<Virtuoso
style={{ height: 600 }}
data={items}
itemContent={(index, item) => <div>{item.name}</div>}
endReached={() => hasMore && loadMore()}
components={{
Footer: () => hasMore ? <div>Loading more...</div> : null
}}
/>
);
}
react-window requires manual scroll tracking using onItemsRendered and managing your own loading state:
// react-window: Manual infinite scroll
import { FixedSizeList as List } from 'react-window';
import { useEffect, useState } from 'react';
function InfiniteWindowList({ items, loadMore, hasMore, totalItemCount }) {
const [loading, setLoading] = useState(false);
const handleItemsRendered = ({ visibleStopIndex }) => {
if (!hasMore || loading) return;
// Trigger load when near the end
if (visibleStopIndex >= items.length - 5) {
setLoading(true);
loadMore().finally(() => setLoading(false));
}
};
return (
<List
height={600}
itemCount={totalItemCount}
itemSize={50}
width="100%"
onItemsRendered={handleItemsRendered}
>
{({ index, style }) => <div style={style}>{items[index]?.name || 'Loading...'}</div>}
</List>
);
}
react-infinite-scroll-component cannot be combined with virtualization — it’s either/or. If you try to wrap a virtualized list inside it, you’ll break scrolling detection.
Need sticky section headers? Or a “Load More” button at the bottom?
react-virtuoso supports this via the components prop:
// react-virtuoso: Custom header and footer
<Virtuoso
data={items}
itemContent={(index, item) => <Item {...item} />}
components={{
Header: () => <div className="sticky-header">Top</div>,
Footer: () => <button onClick={loadMore}>Load More</button>
}}
/>
It also has built-in support for grouped lists with sticky group headers.
react-window requires you to manually compose headers/footers outside the list or use react-window-infinite-loader (a separate package) for more complex scenarios.
react-infinite-scroll-component lets you put anything inside its children, so headers and footers are easy — but again, everything stays in the DOM.
react-window is the fastest because it avoids layout thrashing and uses pure components. But you trade convenience for control.react-virtuoso is slightly slower due to runtime measurements, but the difference is negligible for most apps, and it saves you from writing error-prone sizing logic.react-infinite-scroll-component has no performance optimizations — it’s just a scroll listener. Fine for short lists (<100 items), unusable for long ones.react-infinite-scroll-component if your list can grow beyond a few hundred items. It will cause jank, memory bloat, and slow re-renders.react-window if your items have unpredictable heights and you can’t pre-compute them reliably. You’ll spend more time debugging sizing than building features.react-virtuoso only if you’re in an extreme performance scenario (e.g., rendering 10k+ rows in a trading dashboard) and can guarantee fixed heights — then react-window might edge it out.| Feature | react-infinite-scroll-component | react-virtuoso | react-window |
|---|---|---|---|
| Virtualization | ❌ No | ✅ Yes | ✅ Yes |
| Dynamic Item Heights | ✅ (but renders all) | ✅ Automatic | ⚠️ Manual (itemSize fn) |
| Infinite Scroll Built-in | ✅ Yes | ✅ Via endReached | ❌ Manual implementation |
| Headers / Footers | ✅ Easy (but not virtualized) | ✅ Via components prop | ⚠️ Manual composition |
| Best For | Short paginated lists | Chat, feeds, dynamic content | Fixed-height grids, dashboards |
react-infinite-scroll-component is quick and simple.react-virtuoso is the smoothest experience.react-window gives you raw speed and control.Don’t mix infinite scroll with non-virtualized rendering — it’s a common anti-pattern that leads to degraded performance over time. Choose virtualization early if your dataset could grow large.
Choose react-infinite-scroll-component if you need a simple way to trigger data fetching when the user scrolls to the end of a list, and you're already handling rendering (e.g., with standard React components or another list library). It’s best suited for paginated APIs where you append new items to an existing list. Avoid it if you’re rendering thousands of items at once — it doesn’t virtualize content, so performance will degrade as the list grows.
Choose react-virtuoso when you need a full-featured virtualized list with minimal setup, especially if your items have dynamic or unknown heights, or if you require features like sticky headers, grouped items, or custom scroll containers. It handles complex scenarios out of the box and provides a clean, declarative API. It’s ideal for chat logs, activity feeds, or any list where content size varies.
Choose react-window when you need maximum performance and fine-grained control over virtualization, and your list items have fixed or predictable heights. It’s a lower-level tool that requires more manual configuration but gives you direct access to the rendering logic. Use it in performance-critical applications like dashboards or data grids where every millisecond counts and you can afford to manage item sizing yourself.
Infinite scroll for React. Zero runtime dependencies, IntersectionObserver-based, TypeScript-first. ~4 kB gzipped.
Works with window scroll, fixed-height containers, and custom scrollable parents. Pull-to-refresh and inverse (chat) scroll included. React 17, 18, and 19 compatible.
npm install react-infinite-scroll-component
# or
yarn add react-infinite-scroll-component
# or
pnpm add react-infinite-scroll-component
| API | When to use |
|---|---|
InfiniteScroll component | Most cases, handles loader, endMessage, pull-to-refresh, inverse scroll UI |
useInfiniteScroll hook | Custom UI, you own the markup, the hook manages the observer |
InfiniteScroll componentimport { useState } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
type Item = { id: number; name: string };
function Feed() {
const [items, setItems] = useState<Item[]>(initialItems);
const [hasMore, setHasMore] = useState(true);
const fetchMore = async () => {
const next = await api.getItems({ offset: items.length });
if (next.length === 0) {
setHasMore(false);
return;
}
setItems((prev) => [...prev, ...next]);
};
return (
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
endMessage={<p style={{ textAlign: 'center' }}>All items loaded.</p>}
>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>
);
}
<div id="scrollableDiv" style={{ height: 400, overflow: 'auto' }}>
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
scrollableTarget="scrollableDiv"
>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>
</div>
Pass a ref value directly instead of a string id:
const containerRef = useRef<HTMLDivElement>(null);
<div ref={containerRef} style={{ height: 400, overflow: 'auto' }}>
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
scrollableTarget={containerRef.current}
>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>
</div>;
<div
id="chatBox"
style={{
height: 500,
overflow: 'auto',
display: 'flex',
flexDirection: 'column-reverse',
}}
>
<InfiniteScroll
dataLength={messages.length}
next={loadOlderMessages}
hasMore={hasMore}
loader={<p>Loading older messages...</p>}
inverse={true}
scrollableTarget="chatBox"
style={{ display: 'flex', flexDirection: 'column-reverse' }}
>
{messages.map((msg) => (
<div key={msg.id}>{msg.text}</div>
))}
</InfiniteScroll>
</div>
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
pullDownToRefresh
pullDownToRefreshThreshold={50}
refreshFunction={refreshList}
pullDownToRefreshContent={
<h3 style={{ textAlign: 'center' }}>↓ Pull down to refresh</h3>
}
releaseToRefreshContent={
<h3 style={{ textAlign: 'center' }}>↑ Release to refresh</h3>
}
>
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
</InfiniteScroll>
useInfiniteScroll hookFor when you need full control over your markup. Place the sentinelRef div at the end of your list, the hook fires next() when it enters the viewport.
import { useState } from 'react';
import { useInfiniteScroll } from 'react-infinite-scroll-component';
type Item = { id: number; name: string };
function CustomFeed() {
const [items, setItems] = useState<Item[]>(initialItems);
const [hasMore, setHasMore] = useState(true);
const { sentinelRef, isLoading } = useInfiniteScroll({
next: async () => {
const more = await api.getItems({ offset: items.length });
if (more.length === 0) {
setHasMore(false);
return;
}
setItems((prev) => [...prev, ...more]);
},
hasMore,
dataLength: items.length,
});
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
<li ref={sentinelRef} aria-hidden="true" />
{isLoading && <li>Loading...</li>}
{!hasMore && <li>All items loaded.</li>}
</ul>
);
}
InfiniteScroll is a client component. Fetch initial data in a Server Component, pass it down.
// app/feed/page.tsx, Server Component
import { FeedClient } from './feed-client';
import { db } from '@/lib/db';
export default async function FeedPage() {
const initialItems = await db.items.findMany({
take: 20,
orderBy: { id: 'desc' },
});
return <FeedClient initialItems={initialItems} />;
}
// app/feed/feed-client.tsx, Client Component
'use client';
import { useState } from 'react';
import InfiniteScroll from 'react-infinite-scroll-component';
type Item = { id: string; title: string };
export function FeedClient({ initialItems }: { initialItems: Item[] }) {
const [items, setItems] = useState(initialItems);
const [hasMore, setHasMore] = useState(true);
const fetchMore = async () => {
const res = await fetch(`/api/items?cursor=${items[items.length - 1].id}`);
const next: Item[] = await res.json();
if (next.length === 0) {
setHasMore(false);
return;
}
setItems((prev) => [...prev, ...next]);
};
return (
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
endMessage={<p>You have seen everything.</p>}
>
{items.map((item) => (
<article key={item.id}>{item.title}</article>
))}
</InfiniteScroll>
);
}
import { useInfiniteQuery } from '@tanstack/react-query';
import InfiniteScroll from 'react-infinite-scroll-component';
function PostFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage, pages) =>
lastPage.length === 20 ? pages.length : undefined,
});
const posts = data?.pages.flat() ?? [];
return (
<InfiniteScroll
dataLength={posts.length}
next={fetchNextPage}
hasMore={!!hasNextPage}
loader={isFetchingNextPage ? <p>Loading...</p> : null}
endMessage={<p>All posts loaded.</p>}
>
{posts.map((post) => (
<article key={post.id}>{post.title}</article>
))}
</InfiniteScroll>
);
}
import useSWRInfinite from 'swr/infinite';
import InfiniteScroll from 'react-infinite-scroll-component';
const PAGE_SIZE = 20;
function PostList() {
const { data, size, setSize } = useSWRInfinite(
(index) => `/api/posts?page=${index}&limit=${PAGE_SIZE}`,
fetcher
);
const posts = data ? data.flat() : [];
const hasMore = data ? data[data.length - 1].length === PAGE_SIZE : true;
return (
<InfiniteScroll
dataLength={posts.length}
next={() => setSize(size + 1)}
hasMore={hasMore}
loader={<p>Loading...</p>}
>
{posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</InfiniteScroll>
);
}
| Mode | How to use | Use case |
|---|---|---|
| Window scroll | Omit height and scrollableTarget | Social feeds, blogs, product listings |
| Fixed-height container | Pass height prop | Embedded lists, sidebars |
| Custom scrollable parent | Pass scrollableTarget (element or id) | Existing overflow containers |
InfiniteScroll| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
dataLength | number | yes | - | Current count of rendered items. The component resets its load guard each time this value changes, which allows next() to fire again on the next scroll. |
next | () => void | yes | - | Called once when the sentinel enters the viewport. Append new items to your list state inside this callback; do not replace the existing items. |
hasMore | boolean | yes | - | When false, the observer is disconnected and next() will not be called again. Set it to false when your data source has no more pages. |
loader | ReactNode | yes | - | Rendered below the list while the next page is loading. Displayed between the last item and the bottom sentinel. |
endMessage | ReactNode | no | - | Rendered below the list when hasMore is false. Use it for an "all caught up" or "no more items" message. |
height | number | string | no | - | Creates a fixed-height scroll container wrapping the list. Accepts a pixel number or any CSS length string. Omit this prop to scroll the window instead. |
scrollableTarget | HTMLElement | string | null | no | - | The scrollable ancestor that already provides overflow scrollbars. Pass the element's id string or a direct HTMLElement reference. Required when the scroll container is neither the window nor the height wrapper. |
scrollThreshold | number | string | no | 0.8 | How close to the bottom the user must scroll before next() is called. A fraction like 0.8 means 80% scrolled; a string like "200px" means within 200 px of the bottom edge. |
inverse | boolean | no | false | Reverse scroll direction for chat or messaging UIs. The sentinel moves to the top of the list. Use together with flexDirection: column-reverse on the scroll container. |
pullDownToRefresh | boolean | no | false | Enable pull-to-refresh gesture on touch and mouse. Requires refreshFunction to also be set. |
refreshFunction | () => void | no | - | Called once when the user pulls down past pullDownToRefreshThreshold pixels and releases. Only active when pullDownToRefresh is true. |
pullDownToRefreshThreshold | number | no | 100 | How many pixels the user must pull down before refreshFunction is triggered on release. |
pullDownToRefreshContent | ReactNode | no | - | Content shown inside the pull-to-refresh area while the user is pulling but has not yet reached the threshold. |
releaseToRefreshContent | ReactNode | no | - | Content shown inside the pull-to-refresh area once the threshold is passed and the user can release to refresh. |
onScroll | (e: UIEvent) => void | no | - | Callback fired on every scroll event on the container. Receives the native UIEvent. Useful for syncing UI state with scroll position. |
className | string | no | '' | CSS class name applied to the inner scroll container div. |
style | CSSProperties | no | - | Inline style object applied to the inner scroll container div. Merged with the component's default layout styles. |
role | AriaRole | no | - | Semantic role for the scroll container. Use "list" for item lists, "feed" for activity streams. |
tabIndex | number | no | - | Makes the scroll container focusable. Pass 0 to include it in the natural tab sequence. |
id | string | no | - | DOM id for the container. Useful when other elements reference it via aria-labelledby. |
aria-* | AriaAttributes | no | - | Any React aria-* prop (aria-label, aria-labelledby, aria-describedby, etc.) forwarded to the scroll container. |
hasChildren | boolean | no | - | Set to true when children is a single element or a fragment rather than an array. Helps the component detect whether visible content exists to determine scroll state. |
initialScrollY | number | no | - | Scrolls the window to this Y offset on mount. Useful for restoring a user's scroll position when navigating back to a page. |
Pass role and a label so screen readers can announce the container and its item count correctly:
<InfiniteScroll
role="list"
aria-label="Search results"
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
>
{items.map((item) => (
<div role="listitem" key={item.id}>
{item.name}
</div>
))}
</InfiniteScroll>
Or reference an existing heading via aria-labelledby:
<h2 id="results-heading">Search results</h2>
<InfiniteScroll
role="list"
aria-labelledby="results-heading"
dataLength={items.length}
next={fetchMore}
hasMore={hasMore}
loader={<p>Loading...</p>}
>
useInfiniteScroll| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
dataLength | number | yes | - | Current count of rendered items. The hook resets its load guard whenever this value changes, allowing next() to fire again on the next intersection. |
next | () => void | yes | - | Called once when the sentinel enters the viewport. Append new items to your list state inside this callback; do not replace the existing items. |
hasMore | boolean | yes | - | When false, the IntersectionObserver is disconnected and next() will not be called again. Set it to false when your data source has no more pages. |
scrollThreshold | number | string | no | 0.8 | How close to the edge the sentinel must be before next() fires. A fraction like 0.8 means 80% scrolled; a string like "200px" means within 200 px of the edge. |
scrollableTarget | HTMLElement | string | null | no | - | The scrollable ancestor to use as the observer root. Pass a DOM id string or an HTMLElement reference. When omitted, the observer uses the browser viewport. |
inverse | boolean | no | false | When true, the rootMargin is applied to the top edge instead of the bottom. Place the sentinel at the top of your list and use flexDirection: column-reverse for chat UIs. |
Returns { sentinelRef, isLoading }.
next() fires once when the sentinel enters the viewport, not on every scroll tick. No missed triggers, better performance.useInfiniteScroll hook, low-level hook for building fully custom UIs.throttle-debounce removed.scrollableTarget accepts HTMLElement, pass a ref value directly, not just a string id.scrollableTarget
Thanks goes to these wonderful people (emoji key):
This project follows the all-contributors specification. Contributions of any kind are welcome!