DEV Community

Jessy Shen
Jessy Shen

Posted on Originally published at gridpick-app.netlify.app

Exporting a table that is not a table: React, AG Grid and other div grids

Exporting a table that is not a <table>

The symptom is always the same. You are looking at something that is
unmistakably a table, with headers and rows and sortable columns, and the tool
you reached for says there is no table on this page.

Why it happens

Modern data grids do not use <table> markup. AG Grid, MUI DataGrid, TanStack
Table, Handsontable, Ant Design Table and most in-house React grids render
nested div elements instead, and describe the structure to screen readers with
ARIA roles:

<div role="grid" aria-rowcount="2431">
  <div role="row">
    <div role="columnheader">Ticker</div>
    <div role="columnheader">Last</div>
  </div>
  <div role="row">
    <div role="gridcell">AAPL</div>
    <div role="gridcell">228.14</div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

They do it because table layout is hard to virtualise, hard to pin columns in,
and hard to resize smoothly. The trade is that anything looking for
document.querySelectorAll('table') comes back empty.

How to tell in five seconds

Open DevTools, go to the Console, and run:

document.querySelectorAll('table').length
document.querySelectorAll('[role="grid"], [role="treegrid"], [role="table"]').length
Enter fullscreen mode Exit fullscreen mode

If the first is 0 and the second is not, you have a div grid, and you now know
why the extension found nothing.

What actually works

Look for the grid's own export first. Many admin panels have a Download CSV
or Export button hiding in a toolbar or a row of icons. It is worth ten seconds
of looking, because that route asks the server and gets every row, not just the
ones on screen.

Use an extractor that reads ARIA roles. Anything that walks role="row"
and role="gridcell" handles div grids. GridPick was built around this
case: it reads real table markup and ARIA grids, and it walks open shadow
roots, which matters because querySelectorAll does not cross a shadow
boundary and several component libraries put their grid inside one.

Or paste this into the console. For a one-off, this copies the visible grid
to your clipboard as tab-separated text, ready to paste straight into Excel:

copy([...document.querySelectorAll('[role="row"]')]
  .map(r => [...r.querySelectorAll('[role="gridcell"],[role="columnheader"]')]
    .map(c => c.innerText.trim().replace(/\s+/g, ' '))
    .join('\t'))
  .join('\n'))
Enter fullscreen mode Exit fullscreen mode

copy() is a DevTools helper, not a page function, so this only works from the
console. If the grid is inside an iframe, switch the console context dropdown
to that frame first.

Two things that will still bite you

Only the visible rows are in the page. Virtualised grids create and destroy
rows as you scroll, so the DOM holds fifty of them no matter how many the grid
claims. Nothing running in the browser can see the rest. See
why your table export only has the rows you can see.

Merged headers move your columns. A header cell spanning two columns, or a
section row written as colspan="99", quietly shifts everything under it. A
naive extractor produces a 99-column export from that section row. The fix is
to measure the real column count from the widest rows and clamp every span to
it, which is what GridPick does before it hands you anything.

If the grid is server-driven

When the page fetches its rows from an API, the cleanest route is not the DOM
at all. Open the Network tab, filter to Fetch/XHR, sort the grid or turn a page
so it refetches, and look at the JSON response. Most of the time you can call
that endpoint yourself with a larger page size and get everything in one
request. Copy the request as cURL from the context menu and it carries your
session cookies with it.

Top comments (0)