ReoGrid ReoGrid Web

Building a React invoice with editable formulas in ~60 lines

· unvell team
Building a React invoice with editable formulas in ~60 lines

The “invoice” is the canonical small spreadsheet use case: a header, line items with quantity × price, a subtotal, tax, total. The kind of thing you used to build with a <table> plus a chunk of useEffect doing the arithmetic, and a separate “download as Excel” button that produced a barely-formatted CSV.

Here is the same thing in a real spreadsheet — editable, formula-backed, and one method call away from a downloadable .xlsx — inside a React component:

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

const items: Array<[string, number, number]> = [
  ['Web system development (core)', 1, 200_000],
  ['Add-on: API integration',       2,  30_000],
  ['Testing & QA',                  1,  20_000],
  ['Documentation',                 1,  10_000],
  ['Project management',            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;

        // Title bar
        ws.range('A1:E1').merge().setValue('INVOICE')
          .setStyle({ fontSize: 18, bold: true, textAlign: 'center', backgroundColor: '#1e3a5f', color: '#ffffff' });
        ws.row(0).height = 48;

        // Meta
        ws.cell('A3').setValue('Bill to:').setStyle({ bold: true });
        ws.cell('B3').setValue('Acme Corp., Yamada-san');
        ws.cell('D3').setValue('Date:').setStyle({ textAlign: 'right', color: '#64748b' });
        ws.cell('E3').setValue('2026-05-17');
        ws.cell('D4').setValue('Invoice #:').setStyle({ textAlign: 'right', color: '#64748b' });
        ws.cell('E4').setValue('INV-2026-001');

        // Table header
        const hdr = { bold: true, backgroundColor: '#1e3a5f', color: '#ffffff' };
        ws.cell('A7').setValue('No.').setStyle({ ...hdr, textAlign: 'center' });
        ws.cell('B7').setValue('Item').setStyle(hdr);
        ws.cell('C7').setValue('Qty').setStyle({ ...hdr, textAlign: 'center' });
        ws.cell('D7').setValue('Unit price').setStyle({ ...hdr, textAlign: 'right' });
        ws.cell('E7').setValue('Amount').setStyle({ ...hdr, textAlign: 'right' });

        // Line items — qty and unit price are editable; amount is a formula
        items.forEach(([name, qty, price], i) => {
          const row = 8 + i;                       // 1-based A1 row
          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}`);   // amount formula
          ws.setCellNumberFormat(row - 1, 4, '#,##0');
        });

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

        // Subtotal / tax / total — all formulas
        ws.range(`A${sumRow}:D${sumRow}`).merge();
        ws.cell(`A${sumRow}`).setValue('Subtotal').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('Tax (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('Total (incl. tax)').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 });

        // Borders on the table
        ws.range(`A7:E${totalRow}`).border({ style: 'solid', color: '#475569', width: 1.5 });
      }}
    />
  );
}

That is the entire component. The user can click any qty or unit-price cell, type a new number, hit Enter, and watch the amount, subtotal, tax, and total recalculate immediately. The Invoice demo on this site is essentially this code — try it live.


The three things that make this work

1. setCellInput vs setValue

This is the single most common gotcha. To put a literal value in a cell:

worksheet.cell('A1').setValue('Hello');   // string "Hello"
worksheet.cell('B1').setValue(42);        // number 42
worksheet.cell('C1').setValue('=A1+B1');  // ← string "=A1+B1" — NOT a formula!

To put a formula in a cell, use setCellInput — which goes through the same code path as a user typing into the cell:

worksheet.setCellInput(0, 2, '=A1+B1');   // formula, evaluated on every dependency change

The same applies in reverse: reading cell('C1').value returns the evaluated value (the number 42 after 1+41), not the formula text. To get the formula source, use cell('C1').input.

2. Number formats are Excel-compatible

setCellNumberFormat(row, col, '#,##0') accepts the same format pattern syntax as Excel. A few useful ones for invoices:

PatternRendersNotes
#,##0200,000Thousands separator, no decimals
#,##0.00200,000.00Two decimals
¥#,##0¥200,000Currency prefix
$#,##0.00$1,250.00Dollar
0.00%12.50%Percentage
yyyy-mm-dd2026-05-17ISO date
[Red]-#,##0;0red negativesConditional color section

See the number formatting doc for the full grammar including conditional sections and Japanese era formats.

3. The grid is the source of truth — React subscribes

ReoGrid Web is an imperative canvas component. The right pattern is “the grid owns the data, React subscribes,” not “React state owns the data, the grid renders it.” If you want a summary widget in React that reacts to the user editing the invoice, hook into onCellValueChange:

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

<Reogrid
  style={{ width: '100%', height: 700 }}
  onReady={({ worksheet }) => { /* setup as above */ }}
  onCellValueChange={({ row, column }) => {
    // Read the recomputed total cell after any edit
    const totalRow = 8 + items.length + 2;   // sum + tax + total
    const t = gridRef.current?.worksheet.cell(totalRow, 4).value;
    setTotal(Number(t) || 0);
  }}
/>

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

Do not try to make every cell a React-controlled prop — you would defeat the canvas renderer’s whole reason for existing. The grid is the source of truth for cell data; React state holds derived/summary values.

For a deeper treatment of this pattern, see the Sync Grid with React State recipe.


Adding a “Download as xlsx” button

Pro-tier only, but trivial — saveAsXlsx() builds the file and triggers the browser download in one call:

<button onClick={() => {
  gridRef.current!.worksheet.saveAsXlsx({ filename: 'invoice.xlsx' });
}}>
  Download as xlsx
</button>

Both options are optional: filename defaults to reogrid.xlsx, and sheetName defaults to Sheet1. If you need the raw bytes (for example, to upload the file instead of downloading it), use the lower-level buildXlsxFromSnapshot() re-exported from the package root.

The exported file preserves the merged header, the column widths, all styles, all formulas (Excel re-evaluates them — they are not frozen values), the number formats, and the borders. Open it in Excel; everything that was in the grid is there.


Where to go next

  • Add a logo image: worksheet.images.add({ url: '/logo.png', row: 0, column: 0 })
  • Conditional formatting: highlight overdue line items in red. See Conditional Formatting.
  • Save and restore as JSON: use writeReoGridJson / readReoGridJson for lossless round-trips through your backend without going through xlsx.
  • Vue version: the same logic works with <Reogrid> from @reogrid/pro/vue — drop onReady for @ready.

The companion Build an Invoice recipe covers a Lite-edition version (no SUM, but arithmetic line totals work) for the free tier.

Tomorrow’s article (last in this launch series): how ReoGrid Web compares to AG Grid and Handsontable, and which one is the right fit for which use case.

Related articles

Try ReoGrid Web in your project

Canvas-based Excel-compatible spreadsheet component for React and Vue. Lite is free — start with one npm install.

Stay Updated

Be first to know — get updates as they ship

Get notified of new releases, features, and announcements.
No spam — just updates that matter.