DEV3 Technologies home

Line-items editor with live totals

A reducer-driven React table for adding, editing and removing line items with tax and total maths done in integer cents.

React / Next.jsFormsreactformsinvoicereducer

Live preview

Code

'use client';
import { useReducer } from 'react';

export type Row = { id: string; label: string; qty: number; price: number };
type Action = { type: 'add' } | { type: 'patch'; id: string; patch: Partial<Row> } | { type: 'remove'; id: string };

const blank = (): Row => ({ id: crypto.randomUUID(), label: '', qty: 1, price: 0 });

function reducer(rows: Row[], a: Action): Row[] {
  switch (a.type) {
    case 'add': return [...rows, blank()];
    case 'patch': return rows.map((r) => (r.id === a.id ? { ...r, ...a.patch } : r));
    case 'remove': return rows.filter((r) => r.id !== a.id);
  }
}

// All money maths in integer cents; tax is applied once to the subtotal.
export function totals(rows: Row[], taxRate: number) {
  const subtotal = rows.reduce((sum, r) => sum + r.qty * Math.round(r.price * 100), 0);
  const tax = Math.round(subtotal * taxRate);
  return { subtotal, tax, total: subtotal + tax };
}

const num = (v: string) => (Number.isFinite(Number(v)) ? Number(v) : 0);

export function LineItems({ taxRate = 0.1, currency = 'USD' }: { taxRate?: number; currency?: string }) {
  const [rows, dispatch] = useReducer(reducer, undefined, () => [blank()]);
  const fmt = (cents: number) => new Intl.NumberFormat('en', { style: 'currency', currency }).format(cents / 100);
  const t = totals(rows, taxRate);
  const input = 'w-full rounded border border-slate-300 px-2 py-1 text-sm';

  return (
    <div className="space-y-3">
      {rows.map((r) => (
        <div key={r.id} className="grid grid-cols-[1fr_5rem_7rem_auto] items-center gap-2">
          <input aria-label="Description" className={input} value={r.label} placeholder="Item"
            onChange={(e) => dispatch({ type: 'patch', id: r.id, patch: { label: e.target.value } })} />
          <input aria-label="Quantity" className={input} type="number" min={0} value={r.qty}
            onChange={(e) => dispatch({ type: 'patch', id: r.id, patch: { qty: num(e.target.value) } })} />
          <input aria-label="Unit price" className={input} type="number" min={0} step="0.01" value={r.price}
            onChange={(e) => dispatch({ type: 'patch', id: r.id, patch: { price: num(e.target.value) } })} />
          <button type="button" aria-label="Remove row" onClick={() => dispatch({ type: 'remove', id: r.id })}
            disabled={rows.length === 1} className="px-2 text-slate-500 disabled:opacity-30">&times;</button>
        </div>
      ))}
      <button type="button" onClick={() => dispatch({ type: 'add' })} className="text-sm text-blue-600">+ Add item</button>
      <dl className="ml-auto w-56 space-y-1 text-sm">
        <div className="flex justify-between"><dt>Subtotal</dt><dd>{fmt(t.subtotal)}</dd></div>
        <div className="flex justify-between"><dt>Tax ({taxRate * 100}%)</dt><dd>{fmt(t.tax)}</dd></div>
        <div className="flex justify-between border-t pt-1 font-semibold"><dt>Total</dt><dd>{fmt(t.total)}</dd></div>
      </dl>
    </div>
  );
}

How it works

What it solves

Quotes, invoices and orders all share one form: a list of rows with quantity and price, a tax rate and a total that must always agree with the rows. Getting the arithmetic wrong by a cent is a bug customers notice, so this component keeps the maths in one tested place.

How it works

State lives in a reducer with three actions: add a row, patch a row and remove a row. Rows get a stable id from crypto.randomUUID, so React keeps focus in the right input when rows are deleted from the middle.

The totals function converts each price to integer cents with Math.round(price * 100) before multiplying by quantity, sums the subtotal, then applies the tax rate once on the subtotal and rounds. Doing the tax on the sum, not per row, avoids accumulated rounding drift. The component derives totals during render with no extra state, so the displayed numbers can never lag behind the inputs.

Number inputs store the raw string in the event and convert with Number, treating empty or invalid text as zero, which keeps the editor forgiving while typing.

Adapting it

  • Add a discount field and subtract it before tax.
  • Lift the reducer to a parent with useReducer and pass rows as props to reuse the PDF export.
  • Replace the symbol with Intl.NumberFormat currency formatting.

Pitfalls

  • Never keep totals in state; recompute them.
  • Floating point errors appear if you multiply dollars; always work in cents.
  • Disable the remove button for the last row if an empty list is invalid.

How to use it

  1. Save as components/LineItems.tsx (client component).
  2. No dependencies; Tailwind classes are cosmetic.
  3. Render <LineItems taxRate={0.08} currency="USD" />.
  4. Export totals and Row if you want to reuse them in a PDF or API route.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets