These libraries solve the problem of displaying large lists in React applications without crashing the browser. react-window and react-virtualized use virtualization to render only the visible items, recycling DOM nodes to keep memory usage low. react-infinite-scroll-component appends items to the DOM as the user scrolls, which is simpler but can cause performance issues with very large data sets. react-tiny-virtual-list offers a lightweight virtualization approach with a simpler API than the larger frameworks. Choosing the right tool depends on whether you need true virtualization for performance or a simple append-on-scroll behavior for easier implementation.
When building React applications, rendering long lists can slow down your app or crash the browser. The packages react-window, react-virtualized, react-tiny-virtual-list, and react-infinite-scroll-component solve this problem in different ways. The key difference lies in how they handle DOM nodes: virtualization recycles nodes, while infinite scroll keeps appending them. Let's compare how they work in real engineering scenarios.
react-window renders only the items visible in the viewport. It creates a fixed container and moves items within it as you scroll.
import { FixedSizeList } from 'react-window';
const List = ({ items }) => (
<FixedSizeList height={500} itemCount={items.length} itemSize={35} width={300}>
{({ index, style }) => (
<div style={style}>
{items[index]}
</div>
)}
</FixedSizeList>
);
react-virtualized works similarly but uses a slightly older API structure with explicit row renderers.
import { List } from 'react-virtualized';
const List = ({ items }) => (
<List
height={500}
rowCount={items.length}
rowHeight={35}
width={300}
rowRenderer={({ index, key, style }) => (
<div key={key} style={style}>
{items[index]}
</div>
)}
/>
);
react-tiny-virtual-list provides a similar virtualization experience with a simpler prop structure focused on rendering items.
import VirtualList from 'react-tiny-virtual-list';
const List = ({ items }) => (
<VirtualList
width={300}
height={500}
itemCount={items.length}
itemSize={35}
renderItem={({ index }) => (
<div key={index}>
{items[index]}
</div>
)}
/>
);
react-infinite-scroll-component does not virtualize. It renders all children and appends more as you reach the bottom. This means DOM nodes accumulate over time.
import InfiniteScroll from 'react-infinite-scroll-component';
const List = ({ items, fetchMore }) => (
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={true}
loader={<h4>Loading...</h4>}
height={500}
>
{items.map((item, index) => (
<div key={index}>{item}</div>
))}
</InfiniteScroll>
);
Managing the width and height of the list container is critical for virtualization to calculate visible items correctly.
react-window includes a separate AutoSizer component to fill the parent container automatically.
import { FixedSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
const List = ({ items }) => (
<AutoSizer>
{({ height, width }) => (
<FixedSizeList height={height} width={width} itemCount={items.length} itemSize={35}>
{({ index, style }) => <div style={style}>{items[index]}</div>}
</FixedSizeList>
)}
</AutoSizer>
);
react-virtualized also uses AutoSizer but integrates it tightly within its own ecosystem.
import { List, AutoSizer } from 'react-virtualized';
const List = ({ items }) => (
<AutoSizer>
{({ height, width }) => (
<List
height={height}
width={width}
rowCount={items.length}
rowHeight={35}
rowRenderer={({ index, key, style }) => <div key={key} style={style}>{items[index]}</div>}
/>
)}
</AutoSizer>
);
react-tiny-virtual-list typically requires manual width and height props or CSS styling on the wrapper element.
import VirtualList from 'react-tiny-virtual-list';
const List = ({ items }) => (
<div style={{ width: '100%', height: '500px' }}>
<VirtualList
width={300}
height={500}
itemCount={items.length}
itemSize={35}
renderItem={({ index }) => <div key={index}>{items[index]}</div>}
/>
</div>
);
react-infinite-scroll-component relies on CSS or explicit height props to define the scrollable area.
import InfiniteScroll from 'react-infinite-scroll-component';
const List = ({ items, fetchMore }) => (
<div style={{ height: '500px', overflow: 'auto' }}>
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={true}
loader={<h4>Loading...</h4>}
height={500}
>
{items.map((item, index) => <div key={index}>{item}</div>)}
</InfiniteScroll>
</div>
);
Fetching additional data as the user scrolls is a common requirement. The implementation differs between virtualizers and infinite scroll wrappers.
react-window does not handle data fetching internally. You must track the scroll index and trigger fetches manually.
import { FixedSizeList } from 'react-window';
const List = ({ items, fetchMore }) => {
const onItemsRendered = ({ visibleStopIndex }) => {
if (visibleStopIndex >= items.length - 5) fetchMore();
};
return (
<FixedSizeList height={500} itemCount={items.length} itemSize={35} width={300} onItemsRendered={onItemsRendered}>
{({ index, style }) => <div style={style}>{items[index]}</div>}
</FixedSizeList>
);
};
react-virtualized uses a similar manual approach with the onRowsRendered callback.
import { List } from 'react-virtualized';
const List = ({ items, fetchMore }) => (
<List
height={500}
rowCount={items.length}
rowHeight={35}
width={300}
onRowsRendered={({ stopIndex }) => {
if (stopIndex >= items.length - 5) fetchMore();
}}
rowRenderer={({ index, key, style }) => <div key={key} style={style}>{items[index]}</div>}
/>
);
react-tiny-virtual-list requires you to implement logic outside the component to check scroll position or rendered indices.
import VirtualList from 'react-tiny-virtual-list';
const List = ({ items, fetchMore }) => (
<VirtualList
width={300}
height={500}
itemCount={items.length}
itemSize={35}
renderItem={({ index }) => <div key={index}>{items[index]}</div>}
// You must wrap this or use refs to track scroll for fetchMore
/>
);
react-infinite-scroll-component has data loading built-in via the next prop, making it the easiest to set up for simple feeds.
import InfiniteScroll from 'react-infinite-scroll-component';
const List = ({ items, fetchMore }) => (
<InfiniteScroll
dataLength={items.length}
next={fetchMore}
hasMore={true}
loader={<h4>Loading...</h4>}
height={500}
>
{items.map((item, index) => <div key={index}>{item}</div>)}
</InfiniteScroll>
);
Choosing a library involves considering its future support. Some packages are no longer actively developed.
react-window is actively maintained by Brian Vaughn at Vercel. It is the recommended path for virtualization in the React ecosystem.
// react-window is the current standard
import { FixedSizeList } from 'react-window';
react-virtualized is in maintenance mode. The author recommends migrating to react-window. Do not use it for new projects.
// react-virtualized is legacy
// Only use for existing projects
import { List } from 'react-virtualized';
react-tiny-virtual-list has less frequent updates but remains functional for simple use cases. Check the repository for recent activity before committing.
// react-tiny-virtual-list is stable but less active
import VirtualList from 'react-tiny-virtual-list';
react-infinite-scroll-component is widely used and maintained. It solves a different problem (appending vs virtualizing), so it remains relevant for specific UI patterns.
// react-infinite-scroll-component is active
import InfiniteScroll from 'react-infinite-scroll-component';
| Feature | react-window | react-virtualized | react-tiny-virtual-list | react-infinite-scroll-component |
|---|---|---|---|---|
| Strategy | Virtualization | Virtualization | Virtualization | DOM Appending |
| Maintenance | ā Active | ā ļø Legacy | ā ļø Stable | ā Active |
| API Complexity | Medium | High | Low | Low |
| AutoSizer | ā External Component | ā Built-in | ā Manual | ā Manual |
| Data Loading | ā Manual | ā Manual | ā Manual | ā Built-in |
| Performance | š High | š High | š High | ā ļø Degrades with size |
react-window is the best choice for most modern applications requiring virtualization. It balances performance with a maintainable codebase and active support.
react-infinite-scroll-component is perfect for social feeds or comment sections where simplicity matters more than handling tens of thousands of items.
react-virtualized should be avoided in new work. If you inherit a project using it, plan to migrate to react-window when possible.
react-tiny-virtual-list is a viable alternative if you need virtualization but find react-window too heavy or complex for your specific needs.
Choose react-infinite-scroll-component when you need a simple infinite scroll effect and the total list size will remain manageable (e.g., under 1,000 items). It is best for social media feeds or comment sections where DOM node recycling is less critical than implementation speed.
Choose react-tiny-virtual-list if you need virtualization but want a simpler API with fewer dependencies than react-window. It works well for standard lists with fixed item heights where you do not need the advanced grid or table features of the larger libraries.
Avoid react-virtualized for new projects as it is in maintenance mode and no longer receives feature updates. Only choose this if you are maintaining a legacy codebase that already depends on it, or if you need a specific feature not present in react-window like complex table headers with heavy customization.
Choose react-window for new projects requiring high-performance virtualization. It is the modern successor to react-virtualized, maintained by the same author, and offers a smaller bundle size with a focused API. It is ideal for dashboards, data grids, and any list with thousands of rows where scrolling smoothness is critical.
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!