ReoGrid ReoGrid Web

編集可能な数式付き React 製インボイスを約 60 行で構築する

· unvell team
編集可能な数式付き React 製インボイスを約 60 行で構築する

「インボイス」は小さなスプレッドシートのユースケースの代表例です。ヘッダー、明細行(数量 × 単価)、小計、税、合計。かつてなら <table>useEffect で算術を寄せ集めて、別途「Excel としてダウンロード」ボタンで素っ気ない CSV を吐かせていたような UI です。

これを本物のスプレッドシートで — 編集可能、数式駆動、メソッド呼び出し 1 回でダウンロード可能な .xlsx を生成できる形で — React コンポーネントとして書くと、こうなります。

import { Reogrid, type ReogridInstance } from '@reogrid/pro/react';

const items: Array<[string, number, number]> = [
  ['Web システム開発(コア)',     1, 200_000],
  ['追加: API 連携',              2,  30_000],
  ['テスト&QA',                  1,  20_000],
  ['ドキュメント',                1,  10_000],
  ['プロジェクトマネジメント',    1,  10_000],
];

export default function Invoice() {
  return (
    <Reogrid
      style={{ width: '100%', height: 700 }}
      options={{ animation: true }}
      onReady={({ worksheet: ws }) => {
        ws.setGridSize(25, 5);
        ws.showGridLines = false;

        // タイトルバー
        ws.range('A1:E1').merge().setValue('請求書')
          .setStyle({ fontSize: 18, bold: true, textAlign: 'center', backgroundColor: '#1e3a5f', color: '#ffffff' });
        ws.row(0).height = 48;

        // メタ情報
        ws.cell('A3').setValue('請求先:').setStyle({ bold: true });
        ws.cell('B3').setValue('株式会社 Acme 山田様');
        ws.cell('D3').setValue('日付:').setStyle({ textAlign: 'right', color: '#64748b' });
        ws.cell('E3').setValue('2026-05-17');
        ws.cell('D4').setValue('請求書番号:').setStyle({ textAlign: 'right', color: '#64748b' });
        ws.cell('E4').setValue('INV-2026-001');

        // 表のヘッダー
        const hdr = { bold: true, backgroundColor: '#1e3a5f', color: '#ffffff' };
        ws.cell('A7').setValue('No.').setStyle({ ...hdr, textAlign: 'center' });
        ws.cell('B7').setValue('品目').setStyle(hdr);
        ws.cell('C7').setValue('数量').setStyle({ ...hdr, textAlign: 'center' });
        ws.cell('D7').setValue('単価').setStyle({ ...hdr, textAlign: 'right' });
        ws.cell('E7').setValue('金額').setStyle({ ...hdr, textAlign: 'right' });

        // 明細行 — 数量と単価は編集可能、金額は数式
        items.forEach(([name, qty, price], i) => {
          const row = 8 + i;                       // A1 表記の 1 始まり行番号
          const editable = { backgroundColor: '#fffbeb', color: '#92400e', bold: true };

          ws.cell(`A${row}`).setValue(String(i + 1)).setStyle({ textAlign: 'center' });
          ws.cell(`B${row}`).setValue(name);
          ws.setCellInput(row - 1, 2, String(qty));          ws.setCellStyle(row - 1, 2, { ...editable, textAlign: 'center' });
          ws.setCellInput(row - 1, 3, String(price));        ws.setCellStyle(row - 1, 3, { ...editable, textAlign: 'right' });
          ws.setCellNumberFormat(row - 1, 3, '#,##0');
          ws.setCellInput(row - 1, 4, `=C${row}*D${row}`);   // 金額の数式
          ws.setCellNumberFormat(row - 1, 4, '#,##0');
        });

        const lastItemRow = 8 + items.length - 1;
        const sumRow = lastItemRow + 1, taxRow = sumRow + 1, totalRow = taxRow + 1;

        // 小計 / 税 / 合計 — すべて数式
        ws.range(`A${sumRow}:D${sumRow}`).merge();
        ws.cell(`A${sumRow}`).setValue('小計').setStyle({ textAlign: 'right', backgroundColor: '#f0f4f8' });
        ws.setCellInput(sumRow - 1, 4, `=SUM(E8:E${lastItemRow})`);
        ws.setCellNumberFormat(sumRow - 1, 4, '#,##0');

        ws.range(`A${taxRow}:D${taxRow}`).merge();
        ws.cell(`A${taxRow}`).setValue('消費税 (10%)').setStyle({ textAlign: 'right', backgroundColor: '#f0f4f8' });
        ws.setCellInput(taxRow - 1, 4, `=E${sumRow}*0.1`);
        ws.setCellNumberFormat(taxRow - 1, 4, '#,##0');

        ws.range(`A${totalRow}:D${totalRow}`).merge();
        ws.cell(`A${totalRow}`).setValue('税込合計').setStyle({ bold: true, textAlign: 'right', backgroundColor: '#dbeafe' });
        ws.setCellInput(totalRow - 1, 4, `=E${sumRow}+E${taxRow}`);
        ws.setCellNumberFormat(totalRow - 1, 4, '¥#,##0');
        ws.setCellStyle(totalRow - 1, 4, { bold: true, textAlign: 'right', backgroundColor: '#dbeafe', color: '#1e3a5f', fontSize: 13 });

        // 表に罫線
        ws.range(`A7:E${totalRow}`).border({ style: 'solid', color: '#475569', width: 1.5 });
      }}
    />
  );
}

これでコンポーネントは完成です。ユーザーは数量や単価のセルをクリックして新しい値を入力し、Enter を押せば、金額・小計・税・合計が即座に再計算されます。

本サイトの インボイスデモ は実質これと同じコードで動いています — ぜひライブで触ってみてください。


このコードが成り立つ 3 つのポイント

1. setCellInputsetValue の違い

最もよくハマる落とし穴です。セルにリテラル値を入れるには次のようにします。

worksheet.cell('A1').setValue('Hello');   // 文字列 "Hello"
worksheet.cell('B1').setValue(42);        // 数値 42
worksheet.cell('C1').setValue('=A1+B1');  // ← 文字列 "=A1+B1" — 数式ではありません!

セルに数式を入れたい場合は setCellInput を使います。これはユーザーがセルに入力するのと同じコードパスを通ります。

worksheet.setCellInput(0, 2, '=A1+B1');   // 数式、依存セル変更ごとに再評価

逆方向も同じです。cell('C1').value を読むと 評価後の値1+41 の場合は数値 42)が返り、数式文字列は返りません。数式ソースが欲しい場合は cell('C1').input を使います。

2. 表示形式は Excel 互換

setCellNumberFormat(row, col, '#,##0') は Excel と同じ書式パターン構文を受け付けます。インボイスでよく使うものをいくつか挙げます。

パターン表示備考
#,##0200,000桁区切りあり、小数なし
#,##0.00200,000.00小数 2 桁
¥#,##0¥200,000通貨記号付き
$#,##0.00$1,250.00ドル
0.00%12.50%パーセント
yyyy-mm-dd2026-05-17ISO 形式の日付
[Red]-#,##0;0負数を赤条件付きカラー節

条件節や和暦書式を含む完全な文法は 表示形式のドキュメント を参照してください。

3. 真実の源はグリッド、React はそれを購読する

ReoGrid Web は命令的な Canvas コンポーネントです。正しいパターンは「グリッドがデータを保持し、React が購読する」であり、「React の state がデータを保持し、グリッドがそれを描画する」ではありません。ユーザーがインボイスを編集するたびに反応する React 製のサマリウィジェットが欲しい場合は、onCellValueChange をフックします。

const [total, setTotal] = useState(0);

<Reogrid
  style={{ width: '100%', height: 700 }}
  onReady={({ worksheet }) => { /* 上記と同じセットアップ */ }}
  onCellValueChange={({ row, column }) => {
    // 編集後に再計算された合計セルを読む
    const totalRow = 8 + items.length + 2;   // 小計 + 税 + 合計
    const t = gridRef.current?.worksheet.cell(totalRow, 4).value;
    setTotal(Number(t) || 0);
  }}
/>

<div>合計: <strong>¥{total.toLocaleString()}</strong></div>

すべてのセルを React コントロールド prop にしようとしないでください — それでは Canvas レンダラーが存在する意味がなくなります。セルデータの真実の源はグリッドであり、React state は派生/集計値だけを保持するのが正しい設計です。

このパターンの詳細は Sync Grid with React State レシピ を参照してください。


“xlsx ダウンロード” ボタンを追加する

Pro 専用ですが、saveAsXlsx() を呼ぶだけ。ファイル生成とブラウザのダウンロードトリガを 1 回の呼び出しで完結します。

<button onClick={() => {
  gridRef.current!.worksheet.saveAsXlsx({ filename: 'invoice.xlsx' });
}}>
  xlsx でダウンロード
</button>

オプションはどちらも任意です。filename のデフォルトは reogrid.xlsxsheetName のデフォルトは Sheet1。生バイト列が必要な場合(たとえばダウンロードではなくアップロードしたい場合)は、パッケージのルートから再エクスポートされた低レベル API の buildXlsxFromSnapshot() を使います。

エクスポートされたファイルは、結合済みヘッダー、列幅、すべてのスタイル、すべての数式(Excel 側で再評価されます — 凍結値ではありません)、表示形式、罫線をすべて保持します。Excel で開けば、グリッドにあったものはすべてそこにあります。


次のステップ

  • ロゴ画像を追加する: worksheet.images.add({ url: '/logo.png', row: 0, column: 0 })
  • 条件付き書式: 期限超過の明細行を赤で強調する。条件付き書式 を参照。
  • JSON で保存/復元: writeReoGridJson / readReoGridJson を使えば、xlsx を経由せずバックエンドとのロスレスなラウンドトリップが可能です。
  • Vue 版: 同じロジックは @reogrid/pro/vue<Reogrid> でも動きます。onReady@ready に置き換えてください。

無料の Lite ティア向けには、SUM の代わりに算術ベースで行小計を実装する Build an Invoice レシピ も用意しています。

シリーズ最終回(明日の記事)では、ReoGrid Web と AG Grid/Handsontable の比較と、どのユースケースにどれが合うかを解説します。

ReoGrid Web を試してみる

React/Vue 向けの Canvas ベース Excel 互換スプレッドシートコンポーネント。 Lite は無料 — npm install 一発で始められます。

関連記事

ニュースレター

開発の最新情報をお届けします

新しいリリース・機能追加・お知らせをいち早く受け取るには、
メーリングリストにご登録ください。