ALL WRITING
ENGINEERING

Why I Built My Own Data Grid — and What "Server-Side" Actually Means

Build-vs-buy for data grids, honestly. What pushed me off the shelf, how scaling a table is really a state-ownership problem (not a rendering trick), nested rows, true responsiveness, and when building your own is and isn't worth it.

June 20, 2026 9 min read
webdevreactfrontendtypescript

Every engineer on a data-heavy product eventually faces the same fork: you need a serious table, and the obvious move is to install a mature grid library and move on. I went the other way and built my own. This is the honest version — what pushed me off the shelf, how I actually handle large datasets (which turned out to be more about where state lives than about rendering tricks), nested rows, real responsiveness, and when building your own is and isn't worth it.

One clarification up front, because it separates a sane decision from a reckless one: "build my own grid" did not mean writing sorting, filtering, and pagination from scratch. It meant building my own rendering and interaction layer on top of a headless table engine — a library that owns the data logic and renders nothing. (I built on TanStack Table, but any headless table core gives you the same separation.) You buy correctness; you own every pixel. That distinction is the whole decision.

The limitations that pushed me off the shelf

Full-featured grids are remarkable engineering and frequently the wrong tool for a design-system-driven product.

They own the DOM, and you want to. A batteries-included grid renders its own cells, header, and scroll container with its own markup and class names. The moment your design system has opinions — focus rings, spacing tokens, a specific empty state, a sticky header that matches your shell — you're fighting the library with overrides and !important. You negotiate with the output instead of owning it.

Responsiveness is an afterthought. Spreadsheet-style grids assume a wide viewport and solve "small screen" with horizontal scrolling. That's a desktop grid you can pan around on a phone, not responsive design.

Advanced features are licensed, and bundle cost is real. When you need ~30% of a grid's surface area, you pay — in kilobytes and sometimes dollars — for the other 70%.

The API is shaped like the library, not your domain. I wanted a component whose props read like the problems my app actually has, and that could serve both a simple client-side list and a server-backed table of millions of rows behind one consistent surface.

Handling large datasets: it's a state-ownership problem

Here's where my own first instinct was wrong. The interesting part of scaling a table isn't a rendering trick — it's deciding where the table's state lives. My grid has two modes behind one API, and they differ primarily in state ownership.

For small, bounded datasets, everything runs client-side: the headless engine sorts, filters, and paginates in memory, search is debounced, and the table owns its own state.

For anything unbounded, the grid flips into server mode, and the key decision is that the table's state is owned by the URL, not the component. Page index, page size, sort column and direction, the search term, and the active column filters all live in the query string. The grid doesn't hold that state — it emits intent (page changed, sort changed, filter applied), and those handlers write to the URL.

That single decision buys a stack of properties for free:

  • Deep-linkable and refresh-safe. A filtered, sorted, paginated view is just a URL. Reload it, share it, bookmark it — it restores exactly.
  • The server does the work. Because the params are in the URL, a server component reads them, fetches only the matching page, and streams that page to the client. The browser never holds more than one page, so the table stays responsive against arbitrarily large datasets.
  • The grid stays a pure presentation layer. In server mode the grid doesn't fetch, doesn't filter, doesn't sort — it renders the page it's handed and reports user intent. All the heavy lifting moved to the server and the URL.

This is a deliberate tradeoff worth naming: I chose paginated, URL-driven, server-fetched data over DOM virtualization (windowing). Windowing — rendering only the rows in a long scroll's viewport — is the right answer for an infinite-scroll UX. For a paginated, server-backed, deep-linkable table it's unnecessary complexity. Knowing which problem you have is the senior move.

Two smaller decisions made the two modes coexist cleanly. A pagination adapter — a small interface exposing the pagination controls (set page, can-go-next, and friends) — lets the exact same pagination UI be driven either by the in-memory engine or by URL/server state; the UI doesn't know or care which. And filter options come from different sources per mode: client mode derives them from the loaded data, while server mode receives the valid filter values from the backend, since the client never holds the full dataset to derive them from.

Nested and expandable rows

Expansion is where generic grids get awkward and a custom layer shines. Every row can opt into expanding — gated by a predicate so only rows with something to show get an expander — and when expanded it renders an arbitrary subcomponent in a full-width panel beneath it. That can be a detail form, a chart, or another table instance for true nesting. The grid owns only the expanded state and hands you a slot; because the rendering is mine, the expanded panel is styled like part of the design system rather than a bolted-on tray.

Making it genuinely responsive

This is the part you can't get from a desktop grid. Instead of horizontal scrolling, the grid changes shape by breakpoint. It computes how many columns a given screen can comfortably show, and the columns beyond that threshold don't disappear — they fold into the row's own expandable panel as stacked key/value pairs. On a phone you see one or two meaningful columns and tap to reveal the rest as a clean labeled list; on a tablet, a few more; on a wide desktop, the full table. The expander does double duty — revealing both the detail subcomponent and the folded-away columns — so the interaction model stays consistent as the layout reflows. A purpose-built breakpoint system spanning small phones to ultra-wide displays drives it from real media queries, not guesses.

The build-vs-buy tradeoffs, honestly

What it cost me: I own everything. Accessibility semantics, keyboard navigation, the edge cases in the responsive transform, the maintenance of a non-trivial component family — all mine. A full grid also hands you pivoting, range selection, spreadsheet editing, and integrated charting that I'd have to build from zero if I ever needed them.

What I got: total design-system fidelity, a responsive model that's a feature rather than a compromise, a URL-driven server mode that makes every table view deep-linkable, no licensing, and a bundle carrying only what I use. Building on a headless core meant I bought the hard correctness-critical logic and built only the layer where my product is genuinely unique.

When is this worth it? When your differentiation is presentation and responsiveness, your feature set is focused, and a headless engine can carry the logic. When is it not? When you need spreadsheet-grade capability — pivot tables, massive virtualized editing, in-cell formulas, server-side row grouping. The day those hit your roadmap, buy the grid. That isn't failure; it's the same calculus pointing the other way. The mistake isn't buying or building — it's choosing without being honest about which problem you actually have.

How I'd evolve this

The grid does its job, but the more durable question is what it becomes under sustained pressure — more data shapes, more consumers, stricter accessibility and performance bars. Each direction below is the same move: take a capability that's currently implicit and make it explicit and guaranteed.

Treat the state-driver as a declared contract, not a mode flag. Today "client-side vs server-side" is a boolean that quietly changes who owns sorting, filtering, and pagination. The more mature design names the driver explicitly — in-memory, or URL-backed, or some future remote source — behind a single interface the grid talks to regardless. The pagination adapter already hints at this; generalizing it so every table concern flows through one driver abstraction makes the data source a pluggable decision rather than a fork in the rendering code.

Promote responsiveness from heuristic to per-column policy. The column-folding thresholds are sensible defaults the grid decides. The stronger version lets each column declare its own priority and collapse behavior, turning an internal guess into an explicit contract the domain owns. The principle: push layout decisions outward to where the knowledge lives, rather than centralizing assumptions in the component.

Make accessibility a verified invariant. Owning the DOM is the reason to build your own; it's also an obligation — keyboard semantics, focus management, screen-reader correctness. Maturing the grid means encoding those as tested guarantees rather than trusting them to survive the next markup change. Owning the output earns you the right to render it your way only if you also prove it stays correct.

Make URL state a versioned, validated boundary. Because table state lives in the URL, that query string is a public contract that can outlive the code reading it — a renamed sort key or restructured filter can arrive from an old bookmark. Validating and migrating incoming params turns "a stale link renders a broken table" from a lurking bug into a defined, testable transition. Any state that crosses the boundary of a single session — and a shareable URL absolutely does — deserves schema discipline.

Separate the engine from its skin entirely. The deepest version recognizes that the data-logic layer and the presentation layer want independent lifecycles. A hard boundary between them lets the visual language evolve without touching tested logic, and lets the logic gain capability without destabilizing the UI. A grid, seen this way, isn't one component — it's a stable core with a replaceable surface, and treating it as such is what keeps a bespoke build from slowly rotting into the thing you were trying to avoid.

The throughline is the principle the whole decision rests on, pushed harder: build only the layer where your product is genuinely unique, move state to where it's cheapest to own — the URL, the server — and keep every seam between engine, skin, and data source clean enough that any one can change without asking the others' permission.