Reactで、表のデータをCSVファイルとしてダウンロードする方法

​​React​File API,Papa Parse,Playwright,TypeScript

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で保存(5件)」ボタンを押すと、保存されたファイルの中身として5件の CSV が出る。続けて絞り込みの欄に「ペン」と入れるとボタンが「CSVで保存(2件)」に変わり、押すと2件の CSV が出る
ボタンを2回押して保存する動き。下の枠には、実際に保存されたファイルの中身を出した(カーソル、輪、回数、下の枠は GIF 用に重ねた表示)

表の行から、CSV の文字列を作る

CSV にする関数 toCsv と、保存する関数 downloadCsv を、1つのファイル(csv.ts)にまとめました。どの列を出すかは、見出しと、行から値を取り出す関数の組で渡します。

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, 方眼 という商品名の行は次のようになりました。

text
"A-001,ノート A5, 方眼,280,\r\n"

これを Papa Parse で読み直すと、この行だけが5列に分かれ、価格の列に 方眼 が入りました。

text
2行目: 5列 ["A-001","ノート A5"," 方眼","280",""]

ボタンを押したら、表示中の行を保存する

使う側のコンポーネントでは、絞り込みの文字列を state で持ち、絞り込んだ配列 rows を、表の表示と CSV の両方に使います。こうすると、画面に見えている行と、保存される行がずれません。

tsx
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 で表示しています)。

text
"商品コード,商品名,価格,メモ\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 を別のシステムに取り込む用途なら、' を付けるかどうかは、取り込む側に合わせて決めることになります。

​Posted by 86