Reactで、表のデータをExcelファイルとして保存する方法

​​React​LibreOffice,SheetJS,TypeScript

React の画面に出している表を Excel のファイル(.xlsx)として保存させるには、SheetJS の aoa_to_sheet で行の配列からシートを作り、writeFileXLSX を呼びます。サーバーを用意しなくても、ブラウザの中だけでファイルができて保存されます。

CSV と違って、.xlsx では価格を「数」のまま、入荷日を「日付の表示形式を付けた数」としてセルに入れられます。列の幅や 12,800 のような表示形式も、ファイルの中に書いておけます。商品の一覧を保存するボタンを作り、保存されたファイルを LibreOffice Calc で開いて確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、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、SheetJS(xlsx)0.18.5 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright でボタンを押して、保存されたファイルを SheetJS で読み直したり、LibreOffice Calc 24.2 で開いたりしました。Microsoft Excel で開く確認はしていません。Firefox と Safari、スマートフォンのブラウザでも試していません。SheetJS の配布元にある新しい版(0.20.3)での動作と、npm audit に出た2件目の勧告(ReDoS)の内容も確かめていません。

SheetJS は npm ではなく公式の配布元から入れる

SheetJS のパッケージ名は xlsx です。ただし、npm のレジストリにある xlsx は 0.18.5 で止まっていて、SheetJS の公式ドキュメント(Frameworks and Bundlers)では、SheetJS の配布元にある tgz ファイルを指定して入れる方法が案内されています。

bash
npm i --save https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz

筆者の検証環境からは、この配布元に接続できませんでした。そのため、検証には npm にある 0.18.5 を使っています。0.18.5 を入れて npm audit を実行すると、次の2件が「high」として出ました。

text
xlsx  *
Severity: high
Prototype Pollution in sheetJS - https://github.com/advisories/GHSA-4r6h-8v6p-xvw6
SheetJS Regular Expression Denial of Service (ReDoS) - https://github.com/advisories/GHSA-5pgg-2g8v-p4x9
No fix available

1件目の Prototype Pollution の勧告は、細工されたファイルを読み込んだときに起きるもので、0.19.3 で直っています。ファイルを書き出すだけの使い方は対象外、とも書かれています。ただし、直った版は npm にはなく、公式の配布元から入れる必要があります。この記事で使う関数(aoa_to_sheet、book_new、book_append_sheet、writeFileXLSX)は、現行の公式ドキュメントに載っていて、0.18.5 でも同じ名前で使えました。

行の配列から、シートを作って保存する

保存する関数 downloadXlsx を、xlsx-export.ts にまとめました。どの列を出すかは、見出しと、行から値を取り出す関数の組で渡します。列の幅と表示形式も、列ごとに指定できるようにしました。

ts
import { utils, writeFileXLSX } from 'xlsx';

export type Column<T> = {
  header: string;
  value: (row: T) => string | number | Date;
  width?: number; // 列の幅(文字数)
  format?: string; // 数や日付の表示形式(例:'#,##0')
};

export function downloadXlsx<T>(
  rows: T[],
  columns: Column<T>[],
  filename: string,
  sheetName: string,
) {
  // 1行目に見出し、2行目からデータを並べた「配列の配列」にする
  const aoa = [
    columns.map((c) => c.header),
    ...rows.map((row) => columns.map((c) => c.value(row))),
  ];
  const ws = utils.aoa_to_sheet(aoa);

  // 列の幅
  ws['!cols'] = columns.map((c) => ({ wch: c.width ?? 10 }));

  // 表示形式は、セル(数や日付)ごとに z に入れる
  columns.forEach((c, col) => {
    if (!c.format) return;
    for (let r = 1; r <= rows.length; r++) {
      const cell = ws[utils.encode_cell({ r, c: col })];
      if (cell && cell.t === 'n') cell.z = c.format;
    }
  });

  const wb = utils.book_new();
  utils.book_append_sheet(wb, ws, sheetName);
  writeFileXLSX(wb, filename, { compression: true });
}

aoa_to_sheet は、行ごとの配列を並べた「配列の配列」から、シート(ワークシート)を作る関数です。1つ目の配列が1行目になるので、見出しを先頭に置いています。作ったシートを book_new で作ったブック(ワークブック)に book_append_sheet で加え、writeFileXLSX に渡すと保存が始まります。SheetJS の Export Tutorial によると、writeFile はブラウザではダウンロードを始め、Node.js ではファイルに書き込みます。SheetJS の Writing Files の説明によると、writeFileXLSX は .xlsx のファイルを書き出す関数です。

列の幅は、!cols に { wch: 文字数 } の配列を入れて指定します。Export Tutorial でも、この書き方で列の幅を決めています。保存されたファイルの中(xl/worksheets/sheet1.xml)では、指定した 10、26、8、12、16 に 0.83203125 を足した値が、それぞれの列の幅として書かれていました。

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

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

tsx
import { useState } from 'react';
import type { Column } from './xlsx-export';

type Product = {
  code: string;
  name: string;
  price: number;
  arrived: Date;
  memo: string;
};

const PRODUCTS: Product[] = [
  { code: 'A-001', name: 'ノート A5, 方眼', price: 280, arrived: new Date(2026, 10, 2), memo: '' },
  { code: 'A-002', name: '油性ボールペン 黒', price: 150, arrived: new Date(2026, 10, 5), memo: '10本セットあり' },
  { code: 'A-003', name: 'クリップ "大"', price: 120, arrived: new Date(2026, 9, 28), memo: '在庫は\n残りわずか' },
  { code: 'A-004', name: 'ゲルインクボールペン 赤', price: 180, arrived: new Date(2026, 10, 8), memo: '=1+2' },
  { code: 'A-005', name: 'デスクマット 大判', price: 12800, arrived: new Date(2026, 9, 15), memo: '' },
];

const COLUMNS: Column<Product>[] = [
  { header: '商品コード', value: (p) => p.code, width: 10 },
  { header: '商品名', value: (p) => p.name, width: 26 },
  { header: '価格', value: (p) => p.price, width: 8, format: '#,##0' },
  { header: '入荷日', value: (p) => p.arrived, width: 12, format: 'yyyy-mm-dd' },
  { header: 'メモ', value: (p) => p.memo, width: 16 },
];

export function App() {
  const [query, setQuery] = useState('');
  const [saving, setSaving] = useState(false);
  const rows = PRODUCTS.filter((p) => p.name.includes(query));

  async function handleClick() {
    setSaving(true);
    try {
      // SheetJS はボタンを押したときに読み込む
      const { downloadXlsx } = await import('./xlsx-export');
      downloadXlsx(rows, COLUMNS, 'products.xlsx', '商品一覧');
    } finally {
      setSaving(false);
    }
  }

  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}>
              <td>{p.code}</td>
              <td>{p.name}</td>
              <td>{p.price.toLocaleString('ja-JP')}</td>
              <td>{p.arrived.toLocaleDateString('ja-JP')}</td>
              <td>{p.memo}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <button type="button" disabled={rows.length === 0 || saving} onClick={handleClick}>
        Excelで保存({rows.length}件)
      </button>
    </main>
  );
}

COLUMNS の value では、価格は数のまま、入荷日は Date のまま返しています。表に出すときの書き方(toLocaleString など)は、表のセルの中でだけ使い、保存するデータには混ぜません。表示中の行が0件のときは、ボタンを disabled にしました。

検証環境でボタンを押すと、products.xlsx という名前のファイル(9,438 バイト)が保存されました。「ペン」で絞り込んでから押すと、ボタンの表示は「Excelで保存(2件)」になり、保存されたファイルも見出しと2行(A-002 と A-004)だけでした。

保存したファイルを LibreOffice Calc で開く

保存されたファイルを LibreOffice Calc で開くと、次のように表示されました。

LibreOffice Calc の画面。シート「商品一覧」の A〜E 列に、商品コード・商品名・価格・入荷日・メモの見出しと5件の行が並ぶ。価格は右寄せで 12,800 のようにカンマ付き、入荷日は 2026-11-02 の形、メモの =1+2 は文字のまま表示されている
保存された products.xlsx を LibreOffice Calc 24.2 で開いた画面

シートの名前は book_append_sheet に渡した「商品一覧」になり、列の幅も指定どおりになっています。価格と入荷日は右に寄せられていて、文字ではなく数として扱われていることが分かります。

保存されたファイルを SheetJS で読み直して、セルの種類(t)と値(v)を見ると、次のとおりでした。t=n は数、t=s は文字列のセルです。

text
B2: t=s v="ノート A5, 方眼" z="General" w="ノート A5, 方眼" f=null
C2: t=n v=280 z="#,##0" w="280" f=null
C6: t=n v=12800 z="#,##0" w="12,800" f=null
D2: t=n v=46328 z="yyyy-mm-dd" w="2026-11-02" f=null
E4: t=s v="在庫は\n残りわずか" z="General" w="在庫は\n残りわずか" f=null
E5: t=s v="=1+2" z="General" w="=1+2" f=null

" で囲むなどの処理を書かなくても、カンマや改行を含む値が、そのまま1つのセルに入りました。

日付は数として入り、表示形式で日付に見せる

入荷日のセルは、t=n の数(46328)になっています。SheetJS の Dates and Times の説明のとおり、表計算ソフトの日付は、1900年を起点に数えた日数(シリアル値)で表されます。SheetJS は Date をこの数に変えてセルに入れ、日付に見えるのは、表示形式(z)に日付の形が入っているからです。

表示形式を指定しないと、aoa_to_sheet は日付のセルに m/d/yy を入れました。

text
D2(z を変えない場合): {"v":46328,"z":"m/d/yy","t":"n","w":"11/2/26"}

そこで downloadXlsx では、format を指定した列のセルの z を書き換えています。SheetJS の Number Formats の説明にあるとおり、表示形式はセルごとの z に入れるもので、#,##0 は3桁ごとにカンマを入れる形です。保存されたファイルを LibreOffice Calc で開いて、表示どおりの値を CSV に書き出すと、価格は 12,800、入荷日は 2026-10-15 の形でした。

text
A-005,デスクマット 大判,"12,800",2026-10-15,

Date からシリアル値に変えるときは、実行している環境のタイムゾーンでの日時が使われました。同じ時刻(UTC の 2026-11-02 0時)を表す Date を、Node.js でタイムゾーンを変えて書き出し、読み直した結果です。

text
TZ=Asia/Tokyo: v=46328.375 w=2026-11-02
TZ=UTC: v=46328 w=2026-11-02
TZ=America/Los_Angeles: v=46327.666666666664 w=2026-11-01

ロサンゼルスの時刻では11月1日の夕方なので、日付が1日ずれました。現行版の Dates and Times の説明にも、aoa_to_sheet などは既定で日時をローカルの時刻として扱い、UTC オプションで変えられる、と書かれています。この記事の PRODUCTS のように new Date(2026, 10, 2) で作った日付は、その環境の0時を表すので、ブラウザのタイムゾーンを Asia/Tokyo にした検証では 2026-11-02 のまま保存されました。サーバーから受け取った UTC の日時を Date にして渡すときは、利用者のタイムゾーンによって日付が変わることがあります。

=1+2 は数式ではなく文字列として入る

メモに =1+2 と入っている商品は、セルの種類が t=s(文字列)で、数式(f)はありません。保存されたファイルの中を見ても、数式を表す <f> の要素は1つもありませんでした。LibreOffice Calc で開いても 3 ではなく =1+2 と表示されています。

text
<c r="E5" t="str"><v>=1+2</v></c>

SheetJS はボタンを押したときに読み込む

App では、downloadXlsx を import() で読み込んでいます。esbuild の --splitting でビルドすると、xlsx-export.ts と SheetJS は、別のファイルに分かれました。

text
chunk-SQE76S5B.js 577 バイト(gzip 376 バイト)
main.js 224999 バイト(gzip 69637 バイト)
xlsx-export-R33LAH4X.js 132444 バイト(gzip 42644 バイト)

Playwright で、ブラウザが取りに行った JavaScript のファイルを調べると、ページを開いた直後は main.js と小さい chunk-…js だけでした。xlsx-export-…js(minify 後で約132KB)を取りに行ったのは、ボタンを押したあとでした。保存の機能を使わない人には、SheetJS を読み込ませずに済みます。

import() は非同期なので、handleClick は async にしています。読み込んでいる間に2回押されないように、saving が true の間はボタンを disabled にしました。

書き出しで気をつけること

writeFileXLSX に渡した { compression: true } は、ファイルを小さくするオプションです。Writing Files の説明では、既定は false で、速さを優先して大きめのファイルを作る、とされています。.xlsx は、XML のファイルをまとめた ZIP ファイルで、保存されたファイルの先頭の4バイトも、ZIP を表す 50 4B 03 04 でした。同じ5行を Node.js で書き出して比べると、圧縮なしは 17,161 バイト、圧縮ありは 9,271 バイトでした。

シートの名前には制限があります。book_append_sheet に / を含む名前や、32文字の名前を渡すと、次のエラーになりました。

text
シート名 "在庫/入荷": Sheet name cannot contain : \ / ? * [ ]
シート名 "ああああああああああああああああああああああああああああああああ": Sheet names cannot exceed 31 chars

「商品一覧(2026年11月分)」のような、日本語と全角の括弧を含む名前は通りました。エラーは writeFileXLSX ではなく、book_append_sheet の時点で出ました。利用者が入力した文字列をシートの名前に使うときは、先に : \ / ? * [ ] を取り除き、31文字までに切っておきます。

​Posted by 86