DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

I built a sortable, resizable data table from scratch (no library)

Every admin panel, CRM and dashboard is basically the same screen: a table you can sort, search, resize and page through. We reach for a grid library the moment one shows up, but the core of it is small enough to write by hand in an afternoon. So for Day 30 of DesignFromZero I did exactly that, in vanilla JS, and the interesting part was how many small bugs live in a component that looks this boring.

You can play with the finished thing here: https://dev48v.infy.uk/design/day30-data-table.html

Data first, markup second

The whole thing starts by refusing to hand-write rows. The data is a plain array of objects, and the columns are their own array of descriptors: a key into each row, a label, a type, a starting width, and an optional format for display.

const COLUMNS = [
  { key:"name",   label:"Name",   type:"string", width:190 },
  { key:"salary", label:"Salary", type:"number", width:130,
    align:"right", format:v => "$" + v.toLocaleString() },
  { key:"joined", label:"Joined", type:"date", width:140, format:fmtDate },
];
Enter fullscreen mode Exit fullscreen mode

The rule that saves you later: keep the raw value (a number, an ISO date string) for logic, and only run format when you paint the cell. Format the stored value and your sort breaks, because now you're comparing "$118,000" as text.

The classic sort bug

[9, 80, 100].sort() returns [100, 80, 9]. JavaScript's default sort turns everything into strings, and "100" really does come before "9" alphabetically. This is the single most common table bug there is.

The fix is to branch on the column's declared type. Numbers subtract, dates become Date objects and subtract, strings use localeCompare:

function compare(a, b, type){
  if (type === "number") return a - b;
  if (type === "date")   return new Date(a) - new Date(b);
  return String(a).localeCompare(String(b));
}
Enter fullscreen mode Exit fullscreen mode

Clicking a header cycles through three states, not two: ascending, then descending, then off (back to the natural order). You never write two sort functions for that — you multiply one comparator by a direction of 1 or -1, and skip sorting entirely when it's 0.

Resizing is a colgroup trick

This is the part I expected to be fiddly and turned out to be clean. If you render a <colgroup> with one <col> per column and set table-layout: fixed, then a single <col> controls the width of an entire column. Resizing is just changing that one element's width.

The drag itself uses pointer events, which cover mouse, touch and pen in one code path. On pointerdown I record the start X and the column's current width, then call setPointerCapture so moves keep arriving even when the cursor outruns the thin handle:

function move(ev){
  const w = Math.max(MIN_W, startW + (ev.clientX - startX));
  colEl.style.width = w + "px";        // drive the <col>
}
Enter fullscreen mode Exit fullscreen mode

Two details matter: clamp to a minimum width so a column can't collapse to nothing, and stopPropagation on the handle so the same gesture doesn't also register as a header click and fire a sort.

Filter, with a debounce

Filtering keeps a row if any column contains the query, lower-cased on both sides. Simple. The mistake is running it on every keystroke — on a big table that stutters. So I debounce: start a ~150ms timer on each key and only filter once typing pauses. And because filtering changes how many rows exist, you have to reset to page 1, or you get stranded on page 5 of a two-page result.

Pagination is one slice

A page is a window into the (already filtered and sorted) array. Total pages is ceil(total / pageSize) with a floor of 1. The step people forget is clamping the current page into range before slicing — that's what rescues you when the row count shrinks under you:

const pages = Math.max(1, Math.ceil(total / size));
state.page  = Math.min(Math.max(1, state.page), pages);   // clamp
const start = (state.page - 1) * size;
const pageRows = rows.slice(start, start + size);
Enter fullscreen mode Exit fullscreen mode

Sticky header

Wrap the table in a box with a fixed max-height and overflow: auto — that box scrolls, not the page — then put position: sticky; top: 0 on the header cells. Give the header an opaque background or the rows show through it as they slide under, and a higher z-index so it always wins the overlap.

The one rule that ties it together

Table bugs almost never come from one feature. They come from features fighting each other: you sort, then filter, and the sort is gone; you sort and accidentally mutate the source array so the original order is lost forever. The cure is a single state object and one derive order, every render: filter, then sort a copy, then paginate. Every interaction just updates state and calls render(), which recomputes from scratch.

For huge datasets — 100,000 rows with no pages — you'd swap pagination for virtualization and only render the rows in the viewport. But pagination already keeps the DOM tiny, so it handles the common case with far less code.

Type-aware comparators, a colgroup for widths, a debounced filter, one slice for the page, a sticky header, and one pipeline holding it all together. About 150 lines, no dependencies. Next up: a slide-in drawer.

Top comments (0)