DEV Community

Cover image for React Coding Challenge : Virtualize List - Restrict DOM element
ZeeshanAli-0704
ZeeshanAli-0704

Posted on

React Coding Challenge : Virtualize List - Restrict DOM element

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • 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 --------->|         |
| ↓                                          ↓ |
+----------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

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)