Reactで、表のデータをCSVファイルとしてダウンロードする方法
React の画面に出している表を CSV ファイルとして保存させるには、表の行の配列から CSV の文字列を作り、Blob にして、download 属性を付けた a 要素をクリックさせます。サーバーを用意しなくても、ブラウザの中だけで保存できます。
CSV の文字列を作るときは、カンマや改行を含む値を " で囲む必要があります。そこで、値の囲み方と、表計算ソフトで開く場合に向けた BOM や数式への備えまで含めて、商品の一覧をダウンロードするボタンを作りました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。保存されたファイルの読み直しには Papa Parse(papaparse 5.7.0)を使いました。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright でボタンを押して、保存されたファイルのバイトと中身を調べました。Firefox と Safari、スマートフォンのブラウザでは試していません。Excel などの表計算ソフトで開く確認もしていません。

表の行から、CSV の文字列を作る
CSV にする関数 toCsv と、保存する関数 downloadCsv を、1つのファイル(csv.ts)にまとめました。どの列を出すかは、見出しと、行から値を取り出す関数の組で渡します。
export type Column<T> = {
header: string;
value: (row: T) => string | number;
};
// 表計算ソフトが数式として扱う文字で始まる文字列の前に ' を付ける
function guardFormula(s: string): string {
return /^[=+\-@\t\r\n=+-@]/.test(s) ? `'${s}` : s;
}
// 1つの値を、CSV の1つのフィールドにする
function toField(value: string | number): string {
const s = typeof value === 'number' ? String(value) : guardFormula(value);
// カンマ・"・改行を含む値は " で囲み、中の " は "" にする
return /[",\r\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
}
export function toCsv<T>(rows: T[], columns: Column<T>[]): string {
const lines = [
columns.map((c) => toField(c.header)),
...rows.map((row) => columns.map((c) => toField(c.value(row)))),
];
return lines.map((fields) => fields.join(',')).join('\r\n') + '\r\n';
}
export function downloadCsv(csv: string, filename: string) {
// 先頭の \uFEFF は BOM。Blob の中では EF BB BF の3バイトになる
const blob = new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
値の中にカンマ、"、改行があるときは、値全体を " で囲み、中の " は "" と2つ重ねます。CSV の形式をまとめた RFC 4180 に書かれている書き方です。行の区切りも、RFC 4180 に合わせて \r\n にしました。
この囲み方を省いて、値をそのまま , でつなぐと、ノート A5, 方眼 という商品名の行は次のようになりました。
"A-001,ノート A5, 方眼,280,\r\n"
これを Papa Parse で読み直すと、この行だけが5列に分かれ、価格の列に 方眼 が入りました。
2行目: 5列 ["A-001","ノート A5"," 方眼","280",""]
ボタンを押したら、表示中の行を保存する
使う側のコンポーネントでは、絞り込みの文字列を state で持ち、絞り込んだ配列 rows を、表の表示と CSV の両方に使います。こうすると、画面に見えている行と、保存される行がずれません。
import { useState } from 'react';
import { toCsv, downloadCsv, type Column } from './csv';
type Product = { code: string; name: string; price: number; memo: string };
const PRODUCTS: Product[] = [
{ code: 'A-001', name: 'ノート A5, 方眼', price: 280, memo: '' },
{ code: 'A-002', name: '油性ボールペン 黒', price: 150, memo: '10本セットあり' },
{ code: 'A-003', name: 'クリップ "大"', price: 120, memo: '在庫は\n残りわずか' },
{ code: 'A-004', name: 'ゲルインクボールペン 赤', price: 180, memo: '=1+2' },
{ code: 'A-005', name: 'ファイル', price: 330, memo: '' },
];
const COLUMNS: Column<Product>[] = [
{ header: '商品コード', value: (p) => p.code },
{ header: '商品名', value: (p) => p.name },
{ header: '価格', value: (p) => p.price },
{ header: 'メモ', value: (p) => p.memo },
];
export function App() {
const [query, setQuery] = useState('');
// 画面の表と CSV の両方に、同じ絞り込み後の配列を使う
const rows = PRODUCTS.filter((p) => p.name.includes(query));
return (
<main>
<label>
商品名で絞り込む
<input value={query} onChange={(e) => setQuery(e.target.value)} />
</label>
<table>
<thead>
<tr>
{COLUMNS.map((c) => (
<th key={c.header}>{c.header}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((p) => (
<tr key={p.code}>
{COLUMNS.map((c) => (
<td key={c.header}>{c.value(p)}</td>
))}
</tr>
))}
</tbody>
</table>
<button
type="button"
disabled={rows.length === 0}
onClick={() => downloadCsv(toCsv(rows, COLUMNS), 'products.csv')}
>
CSVで保存({rows.length}件)
</button>
</main>
);
}
CSV の文字列は、ボタンを押したときに onClick の中で作ります。レンダーのたびに作っておく必要はありません。表示中の行が0件のときは、空のファイルを保存させないように、ボタンを disabled にしました。
検証環境で、絞り込みなしでボタンを押すと、products.csv という名前のファイルが保存されました。中身は次のとおりです(改行の文字が分かるように、1行ずつ JSON.stringify で表示しています)。
"商品コード,商品名,価格,メモ\r\n"
"A-001,\"ノート A5, 方眼\",280,\r\n"
"A-002,油性ボールペン 黒,150,10本セットあり\r\n"
"A-003,\"クリップ \"\"大\"\"\",120,\"在庫は\n"
"残りわずか\"\r\n"
"A-004,ゲルインクボールペン 赤,180,'=1+2\r\n"
"A-005,ファイル,330,\r\n"
カンマを含む商品名、" を含む商品名、改行を含むメモが " で囲まれています。このファイルを Papa Parse で読み直すと、5行とも4列に分かれ、errors は空でした。商品名は クリップ "大" に、メモは 在庫は\n残りわずか に戻りました。
「ペン」で絞り込んでから押すと、ボタンの表示は「CSVで保存(2件)」になり、保存されたファイルも見出しと2行だけでした。
Blob と download 属性で保存させる
downloadCsv では、CSV の文字列から Blob を作り、URL.createObjectURL() で blob: から始まる URL にしています。その URL を a 要素の href に入れ、download 属性にファイル名を入れてクリックすると、ブラウザはページを移動せずに、そのファイルを保存します。MDN によると、download 属性が効くのは、同じオリジンの URL と、同じオリジンで作った blob: の URL、data: の URL だけです。保存の前に確認を出すか、自動で保存するかは、ブラウザや設定によって変わります。
a 要素は、ページ(DOM)に追加しないまま click() を呼んでいます。検証に使った Chromium では、これで保存が始まりました。
クリックの直後に URL.revokeObjectURL() を呼んで、URL を手放しています。MDN の blob: URL の説明によると、ブラウザは、ページを閉じたときなどに URL を自動で手放します。それまで待たずに、使い終わった時点で手放すほうがよい、とされています。検証環境では、すぐに手放しても、5件のファイル(276 バイト)も2件のファイル(154 バイト)も最後まで保存されました。手放したあとの URL を fetch すると、TypeError: Failed to fetch になりました。ただし、同じ MDN のページには、早く手放しすぎないように、という注意もあります。クリックの直後に手放す書き方は Chromium でだけ確かめたもので、ほかのブラウザでは試していません。
先頭に BOM を付けておく
new Blob(['\uFEFF', csv]) の \uFEFF は BOM(バイトオーダーマーク)です。MDN の Blob() の説明のとおり、Blob に渡した文字列は UTF-8 になります。検証環境で保存されたファイルの先頭の3バイトは、EF BB BF でした。
BOM を付けたのは、Excel で開く場合に向けてです。UTF-8 の CSV ファイルを Excel で正しく開く方法を説明した、Microsoft のサポートページでは、BOM 付きで保存した UTF-8 の CSV は普通に開ける、とされています。BOM がないファイルは、データの取り込みの機能を使って開く方法が案内されています。筆者は Excel では試していません。
プログラムで読み直すときは、BOM が邪魔になることがあります。MDN の TextDecoder() の説明のとおり、TextDecoder は既定で BOM を読み飛ばします。保存されたファイルを Node.js の TextDecoder で読むと、1文字目は BOM ではなく「商」でした。
数式として読まれる値に ' を付ける
利用者が入力した文字列をそのまま CSV に入れると、表計算ソフトで開いたときに、数式として扱われることがあります。OWASP の CSV Injection の説明では、=、+、-、@、タブ、改行の文字で始まるセルに気を付けるように書かれています。日本語の環境では、全角の =、+、-、@ も数式として扱われることがある、とされています。
guardFormula は、文字列がこれらの文字で始まるときに、前に ' を付ける関数です。メモに =1+2 と入っている商品は、保存されたファイルでは '=1+2 になりました。価格のような数(number)は guardFormula を通さないので、-50 は -50 のまま出力されました。改行で始まる文字列(\n=1+2)には ' が付き、改行を含むので " で囲まれました。
OWASP の同じページには、Excel で保存して開き直すと、' などの付け足した文字が消え、数式として扱われるようになる場合がある、とも書かれています。Excel 向けには、" で囲んだ値の先頭にタブを入れる方法も挙げられていますが、タブはデータに残ります。どの表計算ソフトでも安全になる方法はない、とされています。' を付けた CSV を表計算ソフトで開く動きは、筆者は試していません。
' を付けると、データの中身も変わります。保存したファイルをプログラムで読み直すと、メモは '=1+2 のままでした。CSV を別のシステムに取り込む用途なら、' を付けるかどうかは、取り込む側に合わせて決めることになります。





