Codeskill

Learn to code, step by step

Mini project: performant data table / virtual list

This mini project builds a data table that stays fast with tens of thousands of rows – virtual scrolling, efficient rendering, and optional worker-backed sorting. Plain JavaScript, no framework.

What you are building

A scrollable table showing name, role, and score columns. Only visible rows exist in the DOM at any moment. Sorting and filtering can run on the main thread for moderate sizes or in a Web Worker when the data set is huge.

Suggested layout

virtual-table/
  index.html
  styles.css
  js/
    main.js
    virtual-list.js
    table-view.js
    sort-worker.js
  data/
    generate.js

Generate test data

// generate.js - run once or on load for demo
export function generateRows(count = 20_000) {
  const roles = ['Admin', 'Editor', 'Viewer', 'Guest'];
  return Array.from({ length: count }, (_, i) => ({
    id: i + 1,
    name: `User ${i + 1}`,
    role: roles[i % roles.length],
    score: Math.floor(Math.random() * 1000),
  }));
}

Virtual list core

Fixed row height makes maths simple. Variable row heights need measurement caching – start fixed.

// virtual-list.js
export function createVirtualList({
  container,
  rowHeight,
  rowCount,
  renderRow,
  overscan = 5,
}) {
  const viewport = document.createElement('div');
  viewport.className = 'virtual-viewport';
  viewport.style.height = `${rowCount * rowHeight}px`;
  viewport.style.position = 'relative';

  const windowEl = document.createElement('div');
  windowEl.className = 'virtual-window';
  windowEl.style.position = 'absolute';
  windowEl.style.left = '0';
  windowEl.style.right = '0';

  viewport.appendChild(windowEl);
  container.replaceChildren(viewport);

  let raf = null;

  function update() {
    const scrollTop = container.scrollTop;
    const viewHeight = container.clientHeight;
    const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
    const visibleCount = Math.ceil(viewHeight / rowHeight) + overscan * 2;
    const end = Math.min(rowCount, start + visibleCount);

    windowEl.style.transform = `translateY(${start * rowHeight}px)`;
    windowEl.replaceChildren(
      ...Array.from({ length: end - start }, (_, i) =>
        renderRow(start + i)
      )
    );
  }

  function scheduleUpdate() {
    if (raf) return;
    raf = requestAnimationFrame(() => {
      raf = null;
      update();
    });
  }

  container.addEventListener('scroll', scheduleUpdate, { passive: true });
  update();

  return {
    setRowCount(next) {
      rowCount = next;
      viewport.style.height = `${rowCount * rowHeight}px`;
      scheduleUpdate();
    },
    refresh: scheduleUpdate,
  };
}

Table view

// table-view.js
const ROW_HEIGHT = 40;

export function renderVirtualTable(root, rows) {
  let data = rows;
  const scroller = root.querySelector('[data-table-scroller]');

  const list = createVirtualList({
    container: scroller,
    rowHeight: ROW_HEIGHT,
    rowCount: data.length,
    renderRow(index) {
      const row = data[index];
      const el = document.createElement('div');
      el.className = 'table-row';
      el.style.height = `${ROW_HEIGHT}px`;
      el.innerHTML = `
        <span>${escapeHtml(row.name)}</span>
        <span>${escapeHtml(row.role)}</span>
        <span>${row.score}</span>`;
      return el;
    },
  });

  root.querySelectorAll('[data-sort]').forEach((btn) => {
    btn.addEventListener('click', () => {
      const key = btn.dataset.sort;
      data = [...data].sort((a, b) =>
        String(a[key]).localeCompare(String(b[key]), undefined, { numeric: true })
      );
      list.setRowCount(data.length);
      list.refresh();
    });
  });

  return { updateRows(next) {
    data = next;
    list.setRowCount(data.length);
    list.refresh();
  }};
}

function escapeHtml(text) {
  return String(text)
    .replaceAll('&', '&')
    .replaceAll('', '>')
    .replaceAll('"', '"');
}

For production, build row cells with createElement and textContent instead of template strings – the escape helper is shown here to keep the sample short.

Sort in a worker (optional)

// sort-worker.js
self.onmessage = (event) => {
  const { rows, key } = event.data;
  const sorted = rows.slice().sort((a, b) =>
    String(a[key]).localeCompare(String(b[key]), undefined, { numeric: true })
  );
  self.postMessage({ sorted });
};
// main.js - worker path for heavy sorts
const sorter = new Worker('./js/sort-worker.js', { type: 'module' });

function sortAsync(rows, key) {
  return new Promise((resolve) => {
    sorter.onmessage = (event) => resolve(event.data.sorted);
    sorter.postMessage({ rows, key });
  });
}

CSS essentials

/* styles.css */
.table-scroller {
  height: 400px;
  overflow: auto;
  contain: strict;
}
.table-row {
  display: grid;
  grid-template-columns: 1fr 1fr 80px;
  align-items: center;
  border-bottom: 1px solid #ddd;
}

contain: strict helps the browser isolate layout work inside the scroller.

Checklist

  • 20k rows scroll smoothly – profile with Performance panel.
  • DOM node count stays roughly proportional to viewport height, not row count.
  • Sort completes without long-task warnings (worker or chunked sort).
  • Keyboard scroll and focus still work on the container.
  • Empty and loading states handled gracefully.

That finishes Going deep with JavaScript. You have architecture, state, workers, streams, security, profiling, PWAs, large-app patterns, and two projects that use the lot in plain browser JavaScript.

PreviousMini project: offline-capable notes or task app