Bidirectional Virtual Scrolling for React, Vue, and React Native – Broad Infinite List

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 div wrappers for ul, li, table, tr, or any semantic element via containerAs, as, and itemAs props.
  • πŸ”‘ Imperative API: Exposes a ref interface with scrollToTop, scrollToBottom, scrollTo, scrollToKey, getTopDistance, getBottomDistance, and handleLoad.
  • πŸ“¦ Tiny Bundle: 2 KB gzipped with React, React DOM, and JSX runtime treated as peer dependencies.

Preview

broad-infinite-list

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-list

Basic 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

PropTypeRequiredDefaultDescription
itemsT[]Yesβ€”Current array of items to display.
itemKey(item: T) => string | numberYesβ€”Returns a unique key for each item.
renderItem(item: T) => React.ReactNodeYesβ€”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.
hasPreviousbooleanYesβ€”Signals that more items exist above the current view.
hasNextbooleanYesβ€”Signals that more items exist below the current view.
onItemsChange(items: T[]) => voidNoundefinedCalled after items update due to loading or trimming.
classNamestringNoundefinedCSS class for the outer container element.
itemClassNamestring | (item: T, index: number) => stringNoundefinedCSS class for each item wrapper.
itemStyleCSSProperties | ((item: T, index: number) => CSSProperties | undefined)NoundefinedInline styles for each item element.
listClassNamestringNoundefinedCSS class for the inner list wrapper.
containerAsstringNo"div"HTML tag for the outer container (e.g., "section").
asstringNo"div"HTML tag for the list wrapper (e.g., "ul").
itemAsstringNo"div"HTML tag for each item (e.g., "li").
spinnerRowReact.ReactNodeNoundefinedLoading indicator shown while fetching.
emptyStateReact.ReactNodeNoundefinedContent shown when the items array is empty.
viewCountnumberNo50Maximum DOM nodes to keep rendered; excess items trim from the far end.
thresholdnumberNo10Pixel distance from the scroll edge that triggers a load.
useWindowbooleanNofalseAttaches scroll listeners to the window when true.
disablebooleanNofalseTurns off loading in both directions when true.
onScrollStart() => voidNoundefinedFires when a programmatic scroll adjustment begins.
onScrollEnd() => voidNoundefinedFires when a programmatic scroll adjustment ends.
headerSlot({children}: {children: ReactNode}) => childrenNoundefinedWrapper slot for table header elements.
footerSlot({children}: {children: ReactNode}) => childrenNoundefinedWrapper slot for table footer elements.
upOffsetnumberNoundefinedPixel offset to account for a sticky header when scrolling up.

BidirectionalListRef Methods

PropertyTypeDescription
scrollViewRefRefObject<HTMLElement | null>Direct reference to the scrollable container element.
scrollToTop(behavior?: ScrollBehavior) => voidScrolls to the top of the list.
scrollToBottom(behavior?: ScrollBehavior) => voidScrolls to the bottom of the list.
scrollTo(top: number, behavior?: ScrollBehavior) => voidScrolls to a specific pixel offset from the top.
scrollToKey(key: string | number, behavior?: ScrollBehavior) => voidScrolls to the item matching the given key.
getTopDistance() => numberReturns the current pixel distance from the scroll position to the top.
getBottomDistance() => numberReturns the current pixel distance from the scroll position to the bottom.
handleLoad(direction: "up" | "down", getItems: () => T[] | Promise<T[]>) => voidManually 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.

Add Comment