Building a Data Table Without a Library
The first version of the SeatFinder guest list was a plain list of names. Eighty guests, no search, no sorting, just names in the order they were added. It worked fine when I was testing it with six fake guests. Then I loaded a real wedding list and watched the couple scroll, and scroll, and scroll, looking for one aunt at table 12. (She was near the bottom, obviously. The list had no reason to put her anywhere else.)
Sorting and filtering fixed it. Click a column and the list reorders. Type a few letters and it shrinks to the matches. Nothing about that needs a grid library, and that is the part people skip past. The moment someone says data table, the instinct is to install something. I did the same thing at first. I went looking at grid libraries before I had written a single row of the table, and the libraries were impressive and also completely unnecessary for what I needed. This post is the table I ended up building, the two gotchas I hit, and the point where I would stop hand-rolling it and reach for a library instead.
the gotcha nobody warns you about
Sorting numbers looks like the easy part, and it is where I got bitten. JavaScript's sort with no arguments converts everything to strings first, then compares the strings. That is fine for names. For table numbers it gives you nonsense: [1, 30, 4, 21, 100000].sort() comes back [1, 100000, 21, 30, 4], because as strings, '100000' really does come before '21'. I stared at that output for a solid minute before I believed it. The fix is a compare function, and for numbers it is one subtraction: (a, b) => a - b. Negative means a first, positive means b first, zero means leave them, and every sortable column you build uses that same contract.
const tables = [1, 30, 4, 21, 100000];
tables.sort();
console.log(tables);
// [1, 100000, 21, 30, 4] <- string order, not numeric
// A compare function fixes it: negative means a first,
// positive means b first, zero means leave them.
[...tables].sort((a, b) => a - b);
// [1, 4, 21, 30, 100000]
// toSorted() returns a new array and leaves the original alone.
const sorted = tables.toSorted((a, b) => a - b);One more detail, and it is the one that causes the weird bugs: sort sorts in place. It rearranges the array you call it on and hands you back the same array, not a copy. So if your table sorts the props array directly, you have just reordered the parent's data as a side effect, and the next render does something you did not expect. Copy first, then sort. [...guests].sort(...) is the habit, or toSorted, which returns a new array and leaves the original alone. I use the spread copy out of habit, because it works everywhere, and toSorted when I want the intent obvious.
filtering is one line
Filtering is the easy half. For a guest list, a match is a name that contains what you typed, or a table number that matches. Lowercase both sides so 'rose' finds 'Rose', trim the input so a stray space does not hide every row, and the filter itself is one includes call. It runs on every keystroke, which is fine for eighty guests. (For eighty thousand, you would debounce it. For eighty, typing is the debounce.)
the component
This is the component the demo below runs. A Guest type, a query string, a sort key plus a direction, and a useMemo that filters first and then sorts the copy. The header click toggles the direction if you click the same column twice, and resets to ascending when you switch columns. The count at the bottom is the part people forget and then miss: 'Showing 3 of 8 guests' tells you the filter is doing something, instead of leaving you wondering where the other five went.
type Guest = { id: number; name: string; table: number; status: 'Confirmed' | 'Pending' };
type SortKey = 'name' | 'table' | 'status';
function GuestTable({ guests }: { guests: Guest[] }) {
const [query, setQuery] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('name');
const [direction, setDirection] = useState<1 | -1>(1);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
const filtered = guests.filter(
(guest) =>
guest.name.toLowerCase().includes(q) ||
String(guest.table).includes(q)
);
return [...filtered].sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
const cmp =
typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return cmp * direction;
});
}, [guests, query, sortKey, direction]);
function toggleSort(key: SortKey) {
if (key === sortKey) {
setDirection((d) => (d === 1 ? -1 : 1));
} else {
setSortKey(key);
setDirection(1);
}
}
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder='Filter by name or table'
/>
<table>
<thead>
<tr>
<th onClick={() => toggleSort('name')}>Guest</th>
<th onClick={() => toggleSort('table')}>Table</th>
<th onClick={() => toggleSort('status')}>Status</th>
</tr>
</thead>
<tbody>
{visible.map((guest) => (
<tr key={guest.id}>
<td>{guest.name}</td>
<td>{guest.table}</td>
<td>{guest.status}</td>
</tr>
))}
</tbody>
</table>
<p>
Showing {visible.length} of {guests.length} guests
</p>
</div>
);
}Two details worth calling out. First, the sort key is typed as a union, so a typo in a column name is a red squiggle instead of a column that silently does nothing. Second, the compare handles both kinds of column: numbers subtract, strings use localeCompare, which sorts names the way a person reading the list would expect, accents included. A plain < comparison works off raw code units, so uppercase letters sort before lowercase ones and accented characters land in odd spots. localeCompare is the one I reach for when the data might have an é in it.
| Guest ▲ | Table | Status |
|---|---|---|
| Amira Yusuf | 4 | Pending |
| Jon Park | 1 | Confirmed |
| Layla Haddad | 7 | Confirmed |
| Michael Okafor | 12 | Confirmed |
| Omar Farouk | 1 | Pending |
| Rose Patel | 7 | Confirmed |
| Sarah Chen | 4 | Confirmed |
| Zohan Ali | 12 | Pending |
Showing 8 of 8 guests. Click a column header to sort it.
when I would reach for a grid
So when does the hand-rolled table stop being enough? Three places, from my experience.
- Row count. A few hundred rows render fine as DOM. A hundred thousand do not, and that is where virtualization earns its keep, rendering only the rows on screen.
- Spreadsheet-style editing, where cells are editable in place and changes commit per cell.
- Grouping and pivoting, where the table itself has to reshape the data.
If you need those, get a library. gp-grid is the one making the rounds right now, free and open source with virtual scrolling built in, and it is getting attention because people are tired of paying for the enterprise tier of the big grids. AG Grid is the big one, and its community version is free, but the features people actually want, like row grouping, sit behind the enterprise license. TanStack Table is the middle ground I would pick first, it is headless, so it handles the sorting, filtering, and pagination logic while you keep your own markup. For the guest list, though, all three would have been more code than the table itself. Eighty rows, three columns, two features. The hand-rolled version is about 60 lines, and I can read every one of them.
What I took away from the guest list is that a table is two features, sorting and filtering, and both are a few lines of standard library. The library conversation starts when the data gets big or the interactions get exotic, and for most of the tables I have built, it does not get there. I still check what the libraries offer before I start, because I would rather know what I am skipping than skip it by accident. But the default is the hand-rolled table now, and it has held up for every guest list I have thrown at it.