Every business app has one form that is harder than all the others: the one with line items. A quote, a purchase order, an invoice, a stock transfer. The header is easy (customer, date, currency). The rows are where it gets messy: users add and remove lines, change a quantity and expect the total to update instantly, and then the server rejects line 3 and the error shows up next to line 4.
I have rebuilt this form more times than I would like to admit. This post is the version I now start from, using Laravel, Inertia and React: one useForm holding the header and the lines, stable row keys, validation errors that land on the right row, live totals that never touch floats, and a server that recalculates everything anyway.
The examples use Inertia's React adapter (@inertiajs/react, v3 docs) and Laravel 13's validator.
The problem: rows are not fields
A flat form maps one input to one key. A line-item form maps inputs to positions in an array, and positions change. That causes four classic bugs:
- Using the array index as the React
key. Delete row 2 and React reuses row 2's DOM node for what used to be row 3. Focus jumps, an uncontrolled input keeps the old value, and the user types into the wrong line. - Errors attached to the wrong row. Laravel returns errors like
lines.2.quantity. If the user removed a row after submitting, index 2 now points somewhere else. - Floating point totals.
0.1 * 3is0.30000000000000004. On a quote with forty lines, the footer total drifts away from the sum the server calculates. - Trusting the client. If the browser sends
line_totaland the controller saves it, anyone with devtools can change a price.
Let's fix them one by one.
Step 1: One form, header plus lines
Inertia's useForm happily holds nested data. In the React adapter, setData accepts a key and a value, an object, or an updater function that receives the previous data. When you pass a string key, it is applied as a path, so setData('lines.0.quantity', '5') works too.
I keep a client-only uid on every line for React keys. It never needs to reach the server, so it is stripped in transform.
import { useForm } from '@inertiajs/react' import { newLine, removeLine, updateLine, quoteTotals, formatCents } from './lineItems' export default function QuoteForm({ quote, currencies }) { const form = useForm(`Quote:${quote?.id ?? 'new'}`, { customer_id: quote?.customer_id ?? '', currency: quote?.currency ?? 'EUR', lines: quote?.lines?.length ? quote.lines.map(({ product_id, description, quantity, unit_price }) => ({ ...newLine(), product_id, description, quantity, unit_price, })) : [newLine()], }) const { data, setData, errors, processing, isDirty, clearErrors } = form const totals = quoteTotals(data.lines) form.transform((d) => ({ ...d, lines: d.lines.map(({ uid, ...line }) => line), })) function submit(e) { e.preventDefault() form.post('/quotes', { preserveScroll: true }) } return ( <form onSubmit={submit}> <select value={data.currency} onChange={(e) => setData('currency', e.target.value)}> {currencies.map((c) => <option key={c} value={c}>{c}</option>)} </select> {errors.currency && <p className="error">{errors.currency}</p>} <table> <tbody> {data.lines.map((line, i) => ( <tr key={line.uid}> <td> <input value={line.description} onChange={(e) => setData((d) => updateLine(d, line.uid, { description: e.target.value }))} /> {errors[`lines.${i}.description`] && <p className="error">{errors[`lines.${i}.description`]}</p>} </td> <td> <input inputMode="decimal" value={line.quantity} onChange={(e) => setData((d) => updateLine(d, line.uid, { quantity: e.target.value }))} /> {errors[`lines.${i}.quantity`] && <p className="error">{errors[`lines.${i}.quantity`]}</p>} </td> <td> <input inputMode="decimal" value={line.unit_price} onChange={(e) => setData((d) => updateLine(d, line.uid, { unit_price: e.target.value }))} /> {errors[`lines.${i}.unit_price`] && <p className="error">{errors[`lines.${i}.unit_price`]}</p>} </td> <td>{formatCents(totals.lines[i])}</td> <td> <button type="button" disabled={data.lines.length === 1} onClick={() => { clearErrors() setData((d) => removeLine(d, line.uid)) }} > Remove </button> </td> </tr> ))} </tbody> </table> <button type="button" onClick={() => setData((d) => ({ ...d, lines: [...d.lines, newLine()] }))}> Add line </button> <p>Total: {formatCents(totals.total)} {data.currency}</p> {errors.lines && <p className="error">{errors.lines}</p>} <button type="submit" disabled={processing || !isDirty}>Save quote</button> </form> ) } Three details are doing the real work here:
-
key={line.uid}instead ofkey={i}. Rows keep their identity when other rows are removed. - Updater functions (
setData((d) => ...)) instead of building a new object fromdatacaptured in the render. If the user types fast, every change is applied to the latest state. - The remember key (
Quote:neworQuote:42) as the first argument. Inertia stores the form data and errors in history state, so pressing Back and then Forward does not lose a half-typed quote.
Step 2: Pure helpers you can test without a browser
The row logic lives in a plain module. No React, no Inertia, so it is easy to unit test.
// lineItems.js let seq = 0 export function newLine() { seq += 1 return { uid: `l${Date.now().toString(36)}${seq}`, description: '', quantity: '1', unit_price: '' } } export function updateLine(data, uid, patch) { return { ...data, lines: data.lines.map((l) => (l.uid === uid ? { ...l, ...patch } : l)) } } export function removeLine(data, uid) { return { ...data, lines: data.lines.filter((l) => l.uid !== uid) } } // "12.5" with scale 2 -> 1250n. Returns null for anything that is not a plain decimal. export function toUnits(value, scale) { const s = String(value ?? '').trim().replace(',', '.') const m = /^(\d+)(?:\.(\d+))?$/.exec(s) if (!m || (m[2] ?? '').length > scale) return null return BigInt(m[1] + (m[2] ?? '').padEnd(scale, '0')) } // quantity has 3 decimals, unit price 4, line total is in cents (2), rounded half up export function lineCents(quantity, unitPrice) { const q = toUnits(quantity, 3) const p = toUnits(unitPrice, 4) if (q === null || p === null) return 0n const raw = q * p // scale 7 const cents = raw / 100000n return raw % 100000n >= 50000n ? cents + 1n : cents } export function quoteTotals(lines) { const perLine = lines.map((l) => lineCents(l.quantity, l.unit_price)) return { lines: perLine, total: perLine.reduce((a, b) => a + b, 0n) } } export function formatCents(cents) { const s = cents.toString().padStart(3, '0') return `${s.slice(0, -2)}.${s.slice(-2)}` } Using BigInt for the arithmetic means the footer shows exactly what a decimal calculation gives. A quick check with Node's built-in assert:
import assert from 'node:assert/strict' import { lineCents, quoteTotals, formatCents, toUnits } from './lineItems.js' assert.equal(formatCents(lineCents('3', '0.1')), '0.30') assert.equal(formatCents(lineCents('2.5', '19.99')), '49.98') // 49.975 rounds half up assert.equal(toUnits('1,5', 3), 1500n) assert.equal(toUnits('1e3', 3), null) assert.equal(formatCents(quoteTotals([ { quantity: '3', unit_price: '0.1' }, { quantity: '12', unit_price: '4.2500' }, ]).total), '51.30') The client total is a convenience for the user. It is not the number you save.
Step 3: Validate the array on the server
Laravel validates arrays with wildcard keys, and validation errors come back flattened into dot notation (lines.2.quantity). That is exactly the key format the React form reads.
<?php namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class StoreQuoteRequest extends FormRequest { public function authorize(): bool { return $this->user()->can('create', \App\Models\Quote::class); } protected function prepareForValidation(): void { $this->merge([ 'lines' => collect($this->input('lines', [])) ->map(fn ($line) => is_array($line) ? array_merge($line, [ 'quantity' => str_replace(',', '.', (string) ($line['quantity'] ?? '')), 'unit_price' => str_replace(',', '.', (string) ($line['unit_price'] ?? '')), ]) : $line) ->all(), ]); } public function rules(): array { return [ 'customer_id' => ['required', 'integer', 'exists:customers,id'], 'currency' => ['required', Rule::in(['TRY', 'EUR', 'USD'])], 'lines' => ['required', 'array', 'min:1', 'max:200'], 'lines.*' => ['array:product_id,description,quantity,unit_price'], 'lines.*.product_id' => ['nullable', 'integer', 'exists:products,id', 'distinct'], 'lines.*.description' => ['required', 'string', 'max:255'], 'lines.*.quantity' => ['required', 'regex:/^\d{1,6}(\.\d{1,3})?$/', 'not_in:0,0.0,0.00,0.000'], 'lines.*.unit_price' => ['required', 'regex:/^\d{1,5}(\.\d{1,4})?$/'], ]; } public function messages(): array { return [ 'lines.*.description.required' => 'Line #:position needs a description.', 'lines.*.quantity.regex' => 'Line #:position: quantity must be a number with up to 3 decimals.', 'lines.*.quantity.not_in' => 'Line #:position: quantity cannot be zero.', 'lines.*.unit_price.regex' => 'Line #:position: price must be a number with up to 4 decimals.', 'lines.*.product_id.distinct' => 'Line #:position: this product is already on the quote.', ]; } } A few things worth noticing:
-
array:product_id,description,quantity,unit_pricerejects unknown keys inside each line. If someone sendsline_totalordiscount_override, validation fails instead of the value quietly reaching your model. -
:positionin custom messages is replaced with the 1-based position of the failing element, so users read "Line #3" instead oflines.2.quantity. -
prepareForValidationnormalises a decimal comma, because half of your users will type12,5. -
distinctcatches the same product added twice, a common source of confusing quotes.
The controller then calculates the totals itself, from validated strings, in integer math:
<?php namespace App\Http\Controllers; use App\Http\Requests\StoreQuoteRequest; use App\Models\Quote; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\DB; class QuoteController extends Controller { public function store(StoreQuoteRequest $request): RedirectResponse { $validated = $request->validated(); $quote = DB::transaction(function () use ($validated, $request) { $quote = Quote::create([ 'customer_id' => $validated['customer_id'], 'currency' => $validated['currency'], 'created_by' => $request->user()->id, 'total_cents' => 0, ]); $total = 0; foreach (array_values($validated['lines']) as $position => $line)<...
