import React, { useEffect, useRef, useState } from "react";
const PAGE_SIZE = 10;
const ITEM_HEIGHT = 40; // keep item height consistent for smooth scroll adjustments
const CONTAINER_HEIGHT = 200; // matches style below
const MAX_ITEMS_IN_DOM = 50; // hard cap to keep the DOM small
export default function IntersectionObsVirtualized() {
const [items, setItems] = useState([]); // only the current window
const [minPage, setMinPage] = useState(1); // smallest page currently represented somewhere in the stream
const [maxPage, setMaxPage] = useState(0); // largest page loaded so far
const [hasMoreDown, setHasMoreDown] = useState(true);
const [loadingDown, setLoadingDown] = useState(false);
const [loadingUp, setLoadingUp] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
// how many items we have trimmed from the top (for informational/reference if needed)
const trimmedTopCountRef = useRef(0);
const containerRef = useRef(null);
const fetchPage = async (page) => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts?_limit=${PAGE_SIZE}&_page=${page}`
);
const data = await res.json();
return data;
};
// initial load when dropdown opens
useEffect(() => {
const run = async () => {
if (!showDropdown || items.length > 0) return;
setLoadingDown(true);
const first = await fetchPage(1);
setItems(first);
setMaxPage(1);
setMinPage(1);
if (first.length < PAGE_SIZE) setHasMoreDown(false);
setLoadingDown(false);
};
run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showDropdown]);
const appendPage = async () => {
if (loadingDown || !hasMoreDown) return;
setLoadingDown(true);
const nextPage = maxPage + 1;
const data = await fetchPage(nextPage);
// Append
setItems((prev) => {
const appended = [...prev, ...data];
// If we exceed MAX_ITEMS_IN_DOM, trim from the TOP and adjust scroll to keep the viewport steady
if (appended.length > MAX_ITEMS_IN_DOM) {
const toRemove = appended.length - MAX_ITEMS_IN_DOM;
trimmedTopCountRef.current += toRemove;
// Adjust scrollTop to account for removed items from top
if (containerRef.current) {
containerRef.current.scrollTop -= toRemove * ITEM_HEIGHT;
}
return appended.slice(toRemove);
}
return appended;
});
setMaxPage(nextPage);
if (data.length < PAGE_SIZE) setHasMoreDown(false);
setLoadingDown(false);
};
const prependPage = async () => {
if (loadingUp) return;
// We can only go "up" if we previously trimmed or we have pages < minPage (if your API supports earlier pages)
if (minPage <= 1 && trimmedTopCountRef.current === 0) return;
setLoadingUp(true);
// If we previously trimmed from top, try to refetch the previous page to restore data above
const prevPage = Math.max(1, minPage - 1 + Math.ceil(trimmedTopCountRef.current / PAGE_SIZE));
const data = await fetchPage(prevPage);
setItems((prev) => {
// Prepend
const prepended = [...data, ...prev];
// After prepending, increase scrollTop by newly added height so the user doesn't "jump"
if (containerRef.current) {
containerRef.current.scrollTop += data.length * ITEM_HEIGHT;
}
// If DOM exceeds MAX, trim from bottom (safer when scrolling up) to keep size bounded
if (prepended.length > MAX_ITEMS_IN_DOM) {
return prepended.slice(0, MAX_ITEMS_IN_DOM);
}
return prepended;
});
// Update minPage and trimmedTopCountRef appropriately
if (prevPage < minPage) setMinPage(prevPage);
// Reduce the "trimmed" counter if we just restored some items
const restored = Math.min(trimmedTopCountRef.current, data.length);
trimmedTopCountRef.current = Math.max(0, trimmedTopCountRef.current - restored);
setLoadingUp(false);
};
const onScroll = () => {
const el = containerRef.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
// near bottom: load next page
if (scrollHeight - scrollTop - clientHeight < 80) {
appendPage();
}
// near top: attempt to restore previous (prepend) if possible
if (scrollTop < 60) {
prependPage();
}
};
return (
<div style={{ width: "260px", position: "relative" }}>
<div
onClick={() => setShowDropdown((prev) => !prev)}
style={{
padding: "10px",
border: "1px solid #ccc",
cursor: "pointer",
background: "#fff",
}}
>
Select Post
</div>
{showDropdown && (
<div
id="dropdownContainer"
ref={containerRef}
onScroll={onScroll}
style={{
maxHeight: `${CONTAINER_HEIGHT}px`,
overflowY: "auto",
border: "1px solid #ccc",
marginTop: "5px",
background: "#fff",
position: "absolute",
width: "100%",
zIndex: 100,
}}
>
{items.map((item) => (
<div
key={item.id}
style={{
height: `${ITEM_HEIGHT}px`,
lineHeight: `${ITEM_HEIGHT}px`,
padding: "0 10px",
borderBottom: "1px solid #eee",
cursor: "pointer",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.title}
</div>
))}
{/* Loading indicators */}
{(loadingDown || loadingUp) && (
<div
style={{
padding: "8px",
textAlign: "center",
color: "#777",
fontSize: 12,
}}
>
Loading...
</div>
)}
</div>
)}
</div>
);
}
-
scrollTop
- Vertical scroll offset of the element in CSS pixels.
- 0 means the content is scrolled to the very top.
- Increases as you scroll down. Writable: you can set el.scrollTop = 0 to jump to top.
-
scrollHeight
- Total height of the element’s scrollable content (visible + overflow), in CSS pixels.
- Includes padding but not borders or margins.
- Read-only. Larger than clientHeight when there is overflow.
-
clientHeight
- The inner height of the element’s visible area, in CSS pixels.
- Includes padding, excludes borders, scrollbars, and margins.
- Read-only.
Common patterns:
- Detect near bottom: const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 80;
- Detect near top: const nearTop = el.scrollTop <= 60;
Related:
- Horizontal equivalents: scrollLeft, scrollWidth, clientWidth.
- For the document/page, use document.documentElement.scrollTop (or document.scrollingElement) rather than window.
- Units are CSS pixels; scrollTop is writable, scrollHeight/clientHeight are read-only.
+----- Element (overflow: auto) ---------------+
| ↑ ↑ |
| |<--------- scrollHeight --------->| | <-- total content
| | ┌──┴──┐ |
| | │ ... │ |
| |<-- scrollTop --> │ ... │ |
| | ┌───────────────┐ │ ... │ |
| | │ VISIBLE AREA │ │ ... │ |
| | │ (clientHeight)│ │ ... │ |
| | └───────────────┘ │ ... │ |
| | └──┬──┘ |
| | | |
| |<--------- scrollHeight --------->| |
| ↓ ↓ |
+----------------------------------------------+
Here’s what appendPage does, step by step:
1) Guard clauses and setup
- If we’re already loading downward (loadingDown) or we know there’s no more data (hasMoreDown is false), exit early.
- Set loadingDown to true to prevent concurrent loads.
- nextPage = maxPage + 1 picks the next page after the most recently loaded one.
- Fetch that page: const data = await fetchPage(nextPage).
2) Append new items and keep the DOM small
- setItems uses the functional form to avoid stale state.
- appended = [...prev, ...data] adds the newly fetched items to the end.
- If appended exceeds MAX_ITEMS_IN_DOM, we trim from the top:
- toRemove = appended.length - MAX_ITEMS_IN_DOM calculates how many to drop from the start.
- Increase trimmedTopCountRef.current by toRemove to record how many items have been virtually removed from the top (useful if you later want to restore when scrolling up).
- Adjust scrollTop by subtracting toRemove * ITEM_HEIGHT so the viewport doesn’t visually jump upward after removing items from the DOM. This assumes fixed item height (ITEM_HEIGHT). Without this, the user would suddenly see a jump because the top content disappeared.
- Return appended.slice(toRemove) to keep only the most recent MAX_ITEMS_IN_DOM items in the DOM.
- If we’re under the cap, just return appended.
3) Update paging and flags
- setMaxPage(nextPage) records that we’ve now loaded up to this page.
- If data.length < PAGE_SIZE, we hit the server’s end (no full page), so setHasMoreDown(false) to stop future “down” fetches.
- Finally setLoadingDown(false) to allow future loads.
Why this works
- It “virtualizes” by capping the number of DOM nodes, keeping performance stable even after many scrolls.
- Trimming from the top while adjusting scrollTop preserves the user’s visual position.
- Using the functional setItems prevents race conditions if multiple appends are in-flight (we also guard with loadingDown to avoid that).
Notes/assumptions
- ITEM_HEIGHT must match the actual rendered row height; if rows are variable height, you’ll need a different approach (e.g., react-window, dynamic measurements, or a running height map).
- If the API can return duplicates or out-of-order data, add de-duplication or key-based merging.
opposite of this is prependPage logic
Top comments (0)