Grid CSV export

CSV export

Download the grid as a CSV file, with formula escaping, Excel-safe encoding, and an honest account of anything the file could not contain.

serializeCsv turns a row-model snapshot into a CSV file and defaultSaveFile delivers it to the user's disk. The button below calls exactly that pairing on a real grid — click it and a real products.csv downloads:

Export CSV

A products grid with a real Export CSV button wired to the grid handle's exportCsv, downloading an actual CSV file through defaultSaveFile.

.md

Nothing is checked when that grid loads, so the button exports every row even though it asks for onlySelected: true. Check a few rows and click again to see the file narrow.

The two halves are separate on purpose: the serializer is pure, so you can upload the result, hand it to a worker, or assert on its bytes in a test without a browser.

Getting a file

The framework-agnostic form serializes a snapshot directly, without a React component in the loop at all:

tsx
import {
  defaultSaveFile,
  resolveDataScope,
  serializeCsv,
} from "@pretable/react";
 
const file = serializeCsv({
  rowModelSnapshot,
  columns: grid.getColumns(),
  scope: resolveDataScope(dataHonesty, processing),
});
 
if (file) defaultSaveFile(file, { name: "positions" });

serializeCsv requires a scope, and resolveDataScope is how you answer it honestly. It returns "all" only where the grid can prove the loaded records are the whole matching population. Filtering that the engine did itself qualifies: with no processing prop, or with processing.filter set to anything other than "external", the rows in the model are that population. Under external filtering it takes evidence — resultMeta.total must be exact and count no more records than the grid has loaded. Everything else is "loaded".

That second arm is the reason the helper is public. A server that applied the filter knows a matching count the grid cannot re-derive from the rows it was handed, so an export over those rows is a window onto the result rather than the result, and an estimated total proves nothing either way. A "loaded" scope is exactly what puts the unloaded-rows omission below into the returned file, so hardcoding "all" does not make the export complete — it only stops it saying that it isn't. See matching totals.

From a <PretableSurface>, the same thing is one call on the grid handle — this is exactly how the button above is wired:

tsx
export function ProductGrid({ products }: { products: Product[] }) {
  const grid = useRef<PretableSurfaceGrid<
    Product,
    string,
    readonly PretableColumn<Product>[]
  > | null>(null);
 
  return (
    <>
      <button
        onClick={() => grid.current?.exportCsv({ onlySelected: true })}
        type="button"
      >
        Export CSV
      </button>
      <PretableSurface<Product>
        ariaLabel="Products"
        columns={columns}
        getRowId={(row) => row.id}
        onGridReady={(ready) => {
          grid.current = ready;
        }}
        rowSelectionColumn={{ enabled: true }}
        rows={products}
        viewportHeight={280}
      />
    </>
  );
}

Two things there are load-bearing. <PretableSurface<Product> needs its type argument written out — inference from rows alone widens the row to object and the column types stop lining up. And exportCsv lives on the handle onGridReady gives you rather than on a ref to the component, because the surface ships no toolbar: the button is yours, so the trigger has to be too.

onlySelected restricts the file to the checked rows, as in the example above. An empty selection exports everything, deliberately — a button that silently downloads a zero-row file is indistinguishable from one that is broken. Pass rowIds instead to name the rows yourself; passing both throws, because they are two ways to say the same thing and quietly preferring one would drop rows you asked for.

Three props on the surface shape every export from it, mirroring onCopy and copyToClipboard on the clipboard side: csvOptions sets defaults that a per-call option overrides, onExport replaces the serialization step and can return null to cancel, and saveFile replaces delivery — upload it, hand it to a worker, or call defaultSaveFile(file, { name }) to keep the download and choose the name.

A failed save is not thrown. It is warned and announced, because by then the user has already pressed the button and needs to be told rather than to have an exception raised behind them.

The file tells you what it could not contain

Every mainstream grid ships a partial file silently. AG Grid drops server-side stub rows on a branch whose own comment says so, with no counter and no log. MUI's lazy-loading path emits one blank row per skeleton, so the row count looks right while the data is gone. Neither tells the person who clicked the button.

serializeCsv returns omissions — a list of reasons the file is short, each carrying its own evidence.

kindAlso carriesMeans
unloaded-rowsscopeThe grid held a window, not the population.
collapsed-groupsexpansionOverrideCountGrouping hid rows inside collapsed branches.

complete is derived from omissions.length === 0, never set independently, so it cannot drift from the reasons. A boolean alone was the wrong shape: "is this complete" is an open question, and a flag has to be updated every time a new way to be incomplete is discovered. A union does not — a new reason is a new variant, so an exhaustive switch becomes a compile error rather than a silently wrong true.

The marker is deliberately not written into the file. RFC 4180 has no comment syntax, so a marker row is a data row: pandas reads it as a record with one populated column and NaN across the rest. Trading a silent short file for a silently corrupted one is not an improvement. defaultSaveFile puts -PARTIAL in the filename instead, which travels with the artifact when it is emailed onward and costs the bytes nothing.

Formula escaping

A cell beginning =, +, -, @, tab or carriage return is executed as a formula by Excel, Numbers and Sheets. Escaping is on by default, and it vouches on the value rather than on the column's declared type.

A genuine number, bigint, boolean or Date cannot begin a formula, so those are exempt by their JavaScript type. Everything else is judged on the string it produced.

That distinction is the whole design. Gating on column.type looks equivalent and is not: a row is Record<string, unknown>, so a string from an API sits happily in a column declared number, and its formula ships unescaped. Gating on the leading character instead has the opposite failure — it corrupts negative numbers, which is a shipped bug in Jira (-1000 exported as '-1000 across 9.9.0–9.12.2) and is live in MUI X today.

ts
serializeCsv({
  // …
  options: { escapeFormulas: (value) => value.startsWith("=") },
});

Escaping does cost something, and it is worth knowing before you leave it on. Inside a text column the trigger set catches ordinary data — +1 555 010 0100, @brianlove, -5 to -3 all gain a leading apostrophe. Excel hides it; pandas, Postgres COPY and csv.reader do not. Pass false for a machine-consumed export, or narrow the predicate as above.

Options

OptionTypeRequiredDescription
delimiterstringnoField separator, default ",". Excel follows the OS list separator, which is ; across much of Europe.
bombooleannoPrepend a UTF-8 BOM, default true. Excel does not detect UTF-8 without one.
includeHeadersbooleannoEmit the header row, default true.
escapeFormulasboolean | PretableFormulaEscapePredicatenoDefault true, value-vouched as above.
includeGroupRowsbooleannoEmit group header rows, default true.
includeAggregateRowsbooleannoEmit group aggregate values, default true.
columnIdsreadonly string[]noColumn subset and order. An unknown id throws rather than narrowing the file in silence.
rowIdsReadonlySet<TRowId>noRestrict to these data rows — how selection-only export is expressed.

TRowId is your grid's row-id type, not the string | number union. That is deliberate: typed against the union, a Set<number> on a string-id grid type-checked, matched nothing, and produced a header-only file — a mistyped id silently emptying the export, on a page whose whole subject is refusing to drop rows quietly.

Encoding and format

The output is RFC 4180: CRLF line endings, minimal quoting, and inner quotes doubled. Quoting is minimal rather than universal because quoting every field destroys the one in-band convention CSV has for distinguishing NULL from an empty string — Postgres writes NULL as a bare empty field and an empty string as "" — and it buys nothing against formula injection, which quoting has never prevented in any spreadsheet.

Values come from the column's configured formatter, the same one the grid displays, so the file matches the screen. Note the consequence: a grouped number like 1,234.57 contains the delimiter, so it is quoted, so Excel imports it as text and the column will not sum. That is the cost of honouring a format you configured.

Delivery

defaultSaveFile builds a Blob and clicks a synthetic <a download>.

That is chosen over showSaveFilePicker for one decisive reason: <a download> has no user-activation requirement, so it still works after an await, while the picker is transient-activation-gated and throws once any asynchronous work has happened. Chrome's own guidance is to open the picker before doing the work — which would make the user name a file before knowing whether the export succeeded.

Filenames are sanitized for Windows, macOS and Linux at once, because everything the browser would otherwise do to a name is lossy, silent, and differs by OS: Chromium replaces : with _ on every platform, strips leading dots, and diverges between Windows and POSIX on trailing ones.

Next

Clipboard

Cmd+C copy, which shares this page's formatting pipeline.