Line-Item Forms with Inertia and React: Dynamic Rows, Nested Errors and Live Totals
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 g
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) {
$cents = self::lineCents($line['quantity'], $line['unit_price']);
$total += $cents;
$quote->lines()->create($line + [
'position' => $position + 1,
'line_total_cents' => $cents,
]);
}
$quote->update(['total_cents' => $total]);
return $quote;
});
return to_route('quotes.show', $quote)->with('success', 'Quote saved.');
}
/** qty (3 decimals) x price (4 decimals) -> cents, rounded half up */
private static function lineCents(string $qty, string $price): int
{
$units = static function (string $v, int $scale): int {
[$int, $frac] = array_pad(explode('.', $v, 2), 2, '');
return (int) ($int.str_pad($frac, $scale, '0'));
};
$raw = $units($qty, 3) * $units($price, 4); // scale 7
$cents = intdiv($raw, 100000);
return ($raw % 100000) >= 50000 ? $cents + 1 : $cents;
}
}
The regex limits (up to 6 integer digits for quantity, 5 for price) keep the product below PHP_INT_MAX on 64-bit PHP. If you need bigger numbers, switch to ext-bcmath rather than widening the regex.
Same rounding rule, same scales, same result as the client. If the two ever disagree, the server wins, and you have a bug to look at rather than a silent difference.
Step 4: Warn before leaving with unsaved lines
Losing twenty typed lines to a misclick on the sidebar is painful. Inertia's before event fires before a visit and is cancelable: when registered with router.on, returning false cancels it. router.on also returns a function that removes the listener, which fits useEffect cleanup perfectly.
import { useEffect } from 'react'
import { router } from '@inertiajs/react'
export function useUnsavedGuard(isDirty) {
useEffect(() => {
if (!isDirty) return
return router.on('before', (event) => {
if (event.detail.visit.method !== 'get') return
return confirm('You have unsaved lines. Leave this page?')
})
}, [isDirty])
}
Skipping non-GET visits matters: otherwise your own "Save quote" submit would trigger the warning. Note this only covers Inertia visits. Closing the tab needs a native beforeunload listener, and back/forward history navigation cannot be cancelled.
Common pitfalls
Errors pointing at the wrong row after a delete. Server errors are keyed by index at the time of the request. In the form above, removing a row calls clearErrors() first, so stale lines.N.* messages never land on a shifted row. The user simply submits again.
Index keys. Covered above, but it is the bug I see most in code review. If a list can be reordered or shortened, key={index} is wrong.
ConvertEmptyStringsToNull. Laravel's default middleware turns empty strings into null. A blank optional field inside a line needs nullable, or it fails validation in a way that confuses users.
Sending totals from the client. Do not add line_total or total to the payload "for convenience". With array: on each line, the server rejects it anyway.
Floats in either direction. One parseFloat in a helper undoes the whole approach. Keep amounts as strings until they become integers.
Huge forms re-rendering on every keystroke. For a few dozen lines this design is fine. For hundreds of rows, move each row into a memoised component that receives its line and an onChange(uid, patch) callback, so typing in one row does not re-render all of them.
Summary
- One
useFormholds the header and an array of lines; a remember key keeps drafts across history navigation. - Give every line a client-only
uidfor React keys and strip it intransform. - Use
setDatawith updater functions so rapid edits never lose changes. - Calculate live totals with integer math (
BigIntin JS, integers in PHP), using the same scales and rounding rule on both sides. - Validate with
lines.*rules,array:key lists and:positionmessages; recalculate totals on the server and ignore the client's. - Guard unsaved work with
router.on('before'), and clear errors when rows are removed.
What is the trickiest line-item form you have built: quotes, invoices, or something stranger like production recipes?
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.