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:
A products grid with a real Export CSV button wired to the grid handle's exportCsv, downloading an actual CSV file through defaultSaveFile.
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:
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:
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.
kind | Also carries | Means |
|---|---|---|
unloaded-rows | scope | The grid held a window, not the population. |
collapsed-groups | expansionOverrideCount | Grouping 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.
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
| Option | Type | Required | Description |
|---|---|---|---|
delimiter | string | no | Field separator, default ",". Excel follows the OS list separator, which is ; across much of Europe. |
bom | boolean | no | Prepend a UTF-8 BOM, default true. Excel does not detect UTF-8 without one. |
includeHeaders | boolean | no | Emit the header row, default true. |
escapeFormulas | boolean | PretableFormulaEscapePredicate | no | Default true, value-vouched as above. |
includeGroupRows | boolean | no | Emit group header rows, default true. |
includeAggregateRows | boolean | no | Emit group aggregate values, default true. |
columnIds | readonly string[] | no | Column subset and order. An unknown id throws rather than narrowing the file in silence. |
rowIds | ReadonlySet<TRowId> | no | Restrict 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.