Description:
broad-infinite-list is a React/Vue/React Native component that renders large datasets efficiently by keeping only a fixed number of DOM nodes visible at any time.
The core mechanism is a sliding window of rendered items. As the user scrolls, items entering the viewport get mounted and items leaving the viewport get removed.
The rendered count stays constant regardless of total dataset size, so a list of 1,000,000 records performs the same as a list of 1,000.
Features
- π Bidirectional Scrolling: Loads items in both directions (up and down) as the user scrolls toward either edge.
- β‘ Fixed Render Count: Caps the DOM to a configurable number of nodes (default 50); older items drop out as newer ones load in.
- π Dynamic Heights: Each item can vary in height. No fixed row heights or pre-measurement callbacks needed.
- π Window or Container Scroll: Attaches scroll listeners to either the browser window or a custom container element.
- π§ Custom HTML Tags: Swaps
divwrappers forul,li,table,tr, or any semantic element viacontainerAs,as, anditemAsprops. - π Imperative API: Exposes a ref interface with
scrollToTop,scrollToBottom,scrollTo,scrollToKey,getTopDistance,getBottomDistance, andhandleLoad. - π¦ Tiny Bundle: 2 KB gzipped with React, React DOM, and JSX runtime treated as peer dependencies.
Preview

Use Cases
- Chat Message Lists: Display thousands of messages with smooth bidirectional scrolling, loading older messages as the user scrolls up.
- News Feeds: Show a continuous stream of posts, automatically fetching newer items at the top and older ones at the bottom.
- Log Viewers: Stream realβtime logs with infinite scroll in both directions, without performance degradation.
- Large Data Tables: Render thousands of rows with variable heights, avoiding the overhead of full virtualization.
How to Use It
Installation
npm install broad-infinite-listBasic React Setup
Import the component from the React-specific entry point. The generic type parameter T maps to your data shape.
"use client";
import { useState, useRef } from "react";
import BidirectionalList, {
type BidirectionalListProps,
type BidirectionalListRef,
} from "broad-infinite-list/react";Define your data interface and a starter slice of items:
interface Post {
id: number;
body: string;
}
const ALL_POSTS: Post[] = Array.from({ length: 100_000 }, (_, i) => ({
id: i + 1,
body: `Post number ${i + 1}`,
}));
const VIEW_COUNT = 50;
const PAGE_SIZE = 20;Wiring Up State and Load Callbacks
The component does not manage its own item list. You own the state array and pass it in as items. The onItemsChange callback hands the updated array back to you after the library trims or appends items internally.
function Feed() {
const [items, setItems] = useState<Post[]>(ALL_POSTS.slice(0, VIEW_COUNT));
const listRef = useRef<BidirectionalListRef>(null);
const oldestId = items[items.length - 1]?.id ?? ALL_POSTS.length + 1;
const newestId = items[0]?.id ?? 0;
const hasNext = oldestId > 1;
const hasPrevious = newestId < ALL_POSTS.length;
const handleLoadMore: BidirectionalListProps<Post>["onLoadMore"] = async (
direction,
refItem
) => {
// Simulate a network delay
await new Promise((r) => setTimeout(r, 300));
const idx = ALL_POSTS.findIndex((p) => p.id === refItem.id);
if (direction === "down") {
return ALL_POSTS.slice(idx + 1, idx + PAGE_SIZE + 1);
}
return ALL_POSTS.slice(Math.max(0,idx - PAGE_SIZE), idx);
};
return (
<BidirectionalList<Post>
ref={listRef}
items={items}
itemKey={(item) => item.id}
renderItem={(item) => (
<div style={{ padding: 16, borderBottom: "1px solid #eee" }}>
{item.body}
</div>
)}
onLoadMore={handleLoadMore}
onItemsChange={setItems}
hasPrevious={hasPrevious}
hasNext={hasNext}
viewCount={VIEW_COUNT}
useWindow={true}
spinnerRow={<div style={{ textAlign: "center", padding: 12 }}>Loadingβ¦</div>}
/>
);
}Scrolling Programmatically
Access scroll controls through the forwarded ref:
// Jump to top
listRef.current?.scrollToTop();
// Jump to a specific item by key
listRef.current?.scrollToKey(42);
// Scroll to a pixel offset
listRef.current?.scrollTo(800);
// Read current distance from top or bottom
const distFromTop = listRef.current?.getTopDistance();
const distFromBottom = listRef.current?.getBottomDistance();Using a Scrollable Container Instead of Window
Set useWindow={false} and wrap the list in a fixed-height element with overflow: auto:
<div style={{ height: 600, overflowY: "auto" }}>
<BidirectionalList<Post>
items={items}
itemKey={(item) => item.id}
renderItem={(item) => <div>{item.body}</div>}
onLoadMore={handleLoadMore}
onItemsChange={setItems}
hasPrevious={hasPrevious}
hasNext={hasNext}
useWindow={false}
/>
</div>Semantic HTML Tags
Swap the default div wrappers for list or table elements using containerAs, as, and itemAs:
<BidirectionalList<Post>
containerAs="section"
as="ul"
itemAs="li"
items={items}
itemKey={(item) => item.id}
renderItem={(item) => <span>{item.body}</span>}
onLoadMore={handleLoadMore}
onItemsChange={setItems}
hasPrevious={hasPrevious}
hasNext={hasNext}
/>Vue 3 Usage
For Vue 3, import from the Vue-specific entry. Check the vue-example/src/App.vue file in the repository for a complete working setup.
React Native (Expo)
For React Native, refer to rn-expo-example/app/(tabs)/index.tsx in the repository. Scan the QR code from the project README to open the Expo Go preview directly on a device.
API Reference
BidirectionalList Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
items | T[] | Yes | β | Current array of items to display. |
itemKey | (item: T) => string | number | Yes | β | Returns a unique key for each item. |
renderItem | (item: T) => React.ReactNode | Yes | β | Renders a single item node. |
onLoadMore | (direction: "up" | "down", refItem: T) => Promise<T[]> | Yes | β | Fires when scroll approaches an edge; returns the batch to append or prepend. |
hasPrevious | boolean | Yes | β | Signals that more items exist above the current view. |
hasNext | boolean | Yes | β | Signals that more items exist below the current view. |
onItemsChange | (items: T[]) => void | No | undefined | Called after items update due to loading or trimming. |
className | string | No | undefined | CSS class for the outer container element. |
itemClassName | string | (item: T, index: number) => string | No | undefined | CSS class for each item wrapper. |
itemStyle | CSSProperties | ((item: T, index: number) => CSSProperties | undefined) | No | undefined | Inline styles for each item element. |
listClassName | string | No | undefined | CSS class for the inner list wrapper. |
containerAs | string | No | "div" | HTML tag for the outer container (e.g., "section"). |
as | string | No | "div" | HTML tag for the list wrapper (e.g., "ul"). |
itemAs | string | No | "div" | HTML tag for each item (e.g., "li"). |
spinnerRow | React.ReactNode | No | undefined | Loading indicator shown while fetching. |
emptyState | React.ReactNode | No | undefined | Content shown when the items array is empty. |
viewCount | number | No | 50 | Maximum DOM nodes to keep rendered; excess items trim from the far end. |
threshold | number | No | 10 | Pixel distance from the scroll edge that triggers a load. |
useWindow | boolean | No | false | Attaches scroll listeners to the window when true. |
disable | boolean | No | false | Turns off loading in both directions when true. |
onScrollStart | () => void | No | undefined | Fires when a programmatic scroll adjustment begins. |
onScrollEnd | () => void | No | undefined | Fires when a programmatic scroll adjustment ends. |
headerSlot | ({children}: {children: ReactNode}) => children | No | undefined | Wrapper slot for table header elements. |
footerSlot | ({children}: {children: ReactNode}) => children | No | undefined | Wrapper slot for table footer elements. |
upOffset | number | No | undefined | Pixel offset to account for a sticky header when scrolling up. |
BidirectionalListRef Methods
| Property | Type | Description |
|---|---|---|
scrollViewRef | RefObject<HTMLElement | null> | Direct reference to the scrollable container element. |
scrollToTop | (behavior?: ScrollBehavior) => void | Scrolls to the top of the list. |
scrollToBottom | (behavior?: ScrollBehavior) => void | Scrolls to the bottom of the list. |
scrollTo | (top: number, behavior?: ScrollBehavior) => void | Scrolls to a specific pixel offset from the top. |
scrollToKey | (key: string | number, behavior?: ScrollBehavior) => void | Scrolls to the item matching the given key. |
getTopDistance | () => number | Returns the current pixel distance from the scroll position to the top. |
getBottomDistance | () => number | Returns the current pixel distance from the scroll position to the bottom. |
handleLoad | (direction: "up" | "down", getItems: () => T[] | Promise<T[]>) => void | Manually triggers a load in the given direction (previous items only; requires user-initiated scroll). |
Related Resources
- TanStack Virtual: A headless virtualizer for fixed and variable row heights across React, Vue, Solid, and Svelte.
- react-window: A lightweight list and grid virtualizer that requires fixed item sizes.
- react-virtualized: A full-featured virtualization library with built-in collection, grid, and list components.
FAQs
Q: How do I handle scroll restoration when navigating back to a list page?
A: Store the current items slice and the scroll position (from listRef.current?.getTopDistance()) in your router state or a global store before navigation. On return, re-initialize items with the stored slice and call listRef.current?.scrollTo(savedOffset) after mount.
Q: My sticky header overlaps items when scrolling up. How do I fix that?
A: Pass the header height in pixels to the upOffset prop. The component subtracts that value from its scroll target calculations when loading previous items.
Q: Can I load items from an API rather than a local array?
A: Yes. The onLoadMore callback accepts an async function and returns a Promise<T[]>. Run your fetch call inside it and return the resolved data. The component waits for the promise before appending items and hiding the spinner.
Q: What happens when both hasPrevious and hasNext are false?
A: The component stops triggering onLoadMore in both directions. The current items remain in place. You can still scroll programmatically through the ref methods.





