Broad Infinite List: Scrolling in Both Directions Without Losing Your Mind
Building a chat interface sounds straightforward until you realize the user needs to scroll up for history while new messages stream in at the bottom. Traditional virtualization libraries were designed for static datasets that scroll in one direction. They want you to estimate row heights, manage measurement caches, and bolt on your own loading logic. Broad Infinite List takes a different approach. It maintains a sliding window of real DOM nodes, loads data in both directions through a single callback, and handles dynamic heights without any configuration. The entire package weighs about 2KB gzipped, and it works with React, React Native, and Vue from separate entry points.
Why Two Directions Matter
Most infinite scroll implementations trigger a fetch when you reach the bottom. That works fine for a news feed you only read downward. But chat applications, log viewers, and collaborative document timelines need bidirectional loading. When a user opens a conversation, they see recent messages and scroll up for history. Meanwhile, new messages arrive at the bottom. broad-infinite-list was built from day one for exactly this pattern.
The core mechanism is simple. You give the component an array of items and a viewCount (defaulting to 50). As the user scrolls toward either edge, the onLoadMore callback fires with a direction ("up" or "down") and a reference item. You fetch data, return it, and the component splices it into the list. When the total exceeds viewCount, items are trimmed from the opposite end, and the scroll position is compensated so nothing visually jumps.
What You Get for 2KB
- Bidirectional infinite scrolling that loads items in both directions via a single async callback
- Dynamic row heights with zero configuration or measurement overhead
- Fixed render window that keeps a configurable number of items in the DOM regardless of total dataset size
- No layout shifts when items are trimmed from the DOM
- Window or container scrolling via a
useWindowprop - Semantic HTML support through
as,itemAs, andcontainerAsprops for accessible markup - Scroll restoration that works with client-side routing
- Cross-platform support for React, React Native (Expo), and Vue 3
Getting Started
Install with your package manager of choice:
npm install broad-infinite-list
yarn add broad-infinite-list
Note that broad-infinite-list requires React 19 or later as a peer dependency. For Vue projects, you need Vue 3 or later.
Wiring Up a Chat Feed
The Minimal Setup
The component needs your items, a way to identify them, a render function, and a loader. Here is a stripped-down chat example:
import { useState, useRef } from "react";
import BidirectionalList, {
type BidirectionalListRef,
} from "broad-infinite-list/react";
interface Message {
id: string;
text: string;
sender: string;
timestamp: number;
}
function ChatFeed() {
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [hasPrevious, setHasPrevious] = useState(true);
const [hasNext, setHasNext] = useState(false);
const listRef = useRef<BidirectionalListRef>(null);
const handleLoadMore = async (
direction: "up" | "down",
refItem: Message
): Promise<Message[]> => {
const fetched = await fetchMessages(direction, refItem.id);
if (fetched.length === 0) {
if (direction === "up") setHasPrevious(false);
else setHasNext(false);
}
return fetched;
};
return (
<div style={{ height: "100vh", overflow: "auto" }}>
<BidirectionalList
ref={listRef}
items={messages}
itemKey={(msg) => msg.id}
renderItem={(msg) => (
<div className={`message ${msg.sender}`}>
<p>{msg.text}</p>
<span>{new Date(msg.timestamp).toLocaleTimeString()}</span>
</div>
)}
onLoadMore={handleLoadMore}
onItemsChange={setMessages}
hasPrevious={hasPrevious}
hasNext={hasNext}
viewCount={40}
/>
</div>
);
}
The onItemsChange callback keeps your state in sync whenever items are loaded or trimmed. The viewCount of 40 means only 40 messages live in the DOM at once, no matter how long the conversation history is.
Custom Loading Indicators
You can drop in your own spinner for both loading directions:
<BidirectionalList
items={messages}
itemKey={(msg) => msg.id}
renderItem={(msg) => <MessageBubble message={msg} />}
onLoadMore={handleLoadMore}
onItemsChange={setMessages}
hasPrevious={hasPrevious}
hasNext={hasNext}
spinnerRow={
<div className="loading-indicator">
<Spinner size="sm" />
<span>Loading messages...</span>
</div>
}
emptyState={
<div className="empty-chat">
<p>No messages yet. Say hello!</p>
</div>
}
/>
Semantic HTML with Custom Tags
If you want your list to render as an unordered list for accessibility, the as, containerAs, and itemAs props have you covered:
<BidirectionalList
as="ul"
containerAs="div"
itemAs="li"
items={items}
itemKey={(item) => item.id}
renderItem={(item) => <span>{item.label}</span>}
onLoadMore={handleLoadMore}
onItemsChange={setItems}
hasPrevious={hasPrev}
hasNext={hasNext}
/>
This is uncommon in the virtualization world, where most libraries force you into generic div soup.
Leveling Up
Programmatic Scrolling with Refs
The component exposes a ref with several useful methods. This is handy for a "scroll to bottom" button in a chat interface or jumping to a specific message:
const listRef = useRef<BidirectionalListRef>(null);
function handleNewMessage() {
listRef.current?.scrollToBottom("smooth");
}
function jumpToMessage(messageId: string) {
listRef.current?.scrollToKey(messageId, "smooth");
}
function checkIfNearBottom() {
const distance = listRef.current?.getBottomDistance();
return distance !== undefined && distance < 100;
}
The scrollToKey method finds the item by its key and scrolls to it, which is far more convenient than calculating pixel offsets manually.
Real-Time Updates via WebSocket
For live data like streaming logs or real-time chat, the handleLoad ref method (added in v1.4.0) lets you inject items programmatically without waiting for a scroll event:
const listRef = useRef<BidirectionalListRef>(null);
useEffect(() => {
const ws = new WebSocket("wss://api.example.com/logs");
ws.onmessage = (event) => {
const newEntry = JSON.parse(event.data);
listRef.current?.handleLoad("down", async () => [newEntry]);
};
return () => ws.close();
}, []);
return (
<BidirectionalList
ref={listRef}
items={logEntries}
itemKey={(entry) => entry.id}
renderItem={(entry) => (
<div className={`log-${entry.level}`}>
<code>{entry.timestamp}</code> {entry.message}
</div>
)}
onLoadMore={fetchHistoricalLogs}
onItemsChange={setLogEntries}
hasPrevious={true}
hasNext={false}
/>
);
This pattern works well for any scenario where data arrives from an external source (WebSockets, Server-Sent Events, polling) rather than being triggered by scroll position.
Sticky Headers and Offset Compensation
If your layout includes a fixed header or sticky navigation bar, the upOffset prop compensates for that space so the scroll threshold calculations remain accurate:
<BidirectionalList
items={items}
itemKey={(item) => item.id}
renderItem={(item) => <FeedCard item={item} />}
onLoadMore={handleLoadMore}
onItemsChange={setItems}
hasPrevious={hasPrev}
hasNext={hasNext}
useWindow={true}
upOffset={64}
/>
Setting useWindow to true makes the component use the browser window scroll instead of a container element, which is typical for full-page feed layouts. The upOffset of 64 accounts for a 64-pixel sticky header.
How It Compares
The elephant in the room is how broad-infinite-list stacks up against established libraries like TanStack Virtual, react-window, and react-virtualized. The key difference is philosophical. Those libraries virtualize by calculating positions based on item sizes, which means you either provide fixed heights or implement measurement logic. broad-infinite-list sidesteps this entirely by keeping a sliding window of real DOM nodes and compensating scroll position when trimming.
The trade-off is that the scrollbar thumb will not accurately reflect your position in a million-item dataset since only viewCount items exist in the DOM. You also cannot jump directly to item number 50,000 without loading sequentially. But for chat, feeds, and logs where users scroll linearly, this is rarely a problem.
Wrapping Up
Broad Infinite List fills a specific gap in the React ecosystem: bidirectional infinite scrolling with dynamic heights and no fuss. It does not try to be a general-purpose virtualization toolkit. Instead, it does one thing well, keeping a lean window of DOM nodes while you scroll in both directions. At 2KB gzipped with a single dependency, it adds almost nothing to your bundle. If you are building a chat interface, a log viewer, or any feed that loads in both directions, broad-infinite-list is worth a serious look.