Pada aplikasi bisnis, form jarang berhenti pada input sederhana seperti nama, email, atau satu dropdown. Kasus nyata seperti invoice, pesanan, purchase request, atau retur barang hampir selalu melibatkan nested data, daftar item dinamis, checkbox yang berubah sesuai konteks, dan relasi hasMany di backend. Jika struktur data tidak dikelola dengan baik, kode frontend menjadi rapuh, validasi backend sulit dipetakan, dan pengalaman pengguna cepat menurun.
Di ekosistem Inertia.js, useForm adalah alat yang sangat berguna untuk menangani submit, status loading, reset, error validasi, dan transformasi payload. Namun, banyak tantangan muncul saat data sudah berbentuk array bertingkat, misalnya items[0][product_id], items[1][qty], atau field opsional yang perlu dibersihkan sebelum dikirim. Artikel ini membahas pendekatan praktis untuk mengelola form semacam itu dengan contoh invoice yang memiliki banyak baris item.
Memahami Tantangan Form Kompleks di Inertia
Untuk form sederhana, kita bisa menyimpan semua field di dalam satu objek dan mengirimkannya langsung. Masalah muncul ketika:
- Satu dokumen memiliki banyak item.
- Setiap item memiliki field sendiri seperti produk, kuantitas, harga, diskon, dan catatan.
- Ada checkbox dinamis seperti is taxable, is urgent, atau include shipping.
- Backend Laravel mengharapkan struktur validasi nested, misalnya
items.*.product_id. - Error validasi perlu ditampilkan di input yang tepat, termasuk per baris item.
Dalam kondisi seperti ini, tujuan kita bukan hanya membuat form “berfungsi”, tetapi juga memastikan struktur data konsisten dari frontend ke backend. Konsistensi ini penting agar validasi, penyimpanan, dan proses update data tetap mudah dipelihara.
Membangun Struktur Form Invoice dengan useForm
Anggap kita memiliki form invoice dengan informasi utama dan sejumlah item. Di sisi frontend, bentuk datanya sebaiknya mendekati kebutuhan backend agar transformasi tidak terlalu rumit.
import { useForm } from '@inertiajs/react'
const emptyItem = () => ({
product_id: '',
description: '',
qty: 1,
price: 0,
taxable: false,
})
export default function InvoiceForm({ customers, products }) {
const form = useForm({
customer_id: '',
invoice_date: '',
due_date: '',
notes: '',
send_email: false,
items: [emptyItem()],
})
const addItem = () => {
form.setData('items', [...form.data.items, emptyItem()])
}
const removeItem = (index) => {
const items = form.data.items.filter((_, i) => i !== index)
form.setData('items', items.length ? items : [emptyItem()])
}
const updateItem = (index, field, value) => {
const items = [...form.data.items]
items[index] = { ...items[index], [field]: value }
form.setData('items', items)
}
const submit = (e) => {
e.preventDefault()
form.post(route('invoices.store'))
}
}Ada beberapa prinsip penting dalam struktur di atas:
- Gunakan bentuk data yang eksplisit. Jangan menunggu field muncul “secara alami” setelah interaksi user. Inisialisasi default membantu mencegah error undefined.
- Gunakan fungsi pembuat item kosong. Ini memudahkan penambahan baris baru dengan struktur konsisten.
- Hindari mutasi langsung. Salin array atau object sebelum mengubahnya agar reaktivitas tetap aman.
Pada komponen, field item biasanya dirender dengan map. Setiap input mengacu pada indeks item yang bersangkutan.
<form onSubmit={submit}>
<select
value={form.data.customer_id}
onChange={e => form.setData('customer_id', e.target.value)}
>
<option value="">Pilih customer</option>
{customers.map(customer => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
{form.errors.customer_id && <div>{form.errors.customer_id}</div>}
{form.data.items.map((item, index) => (
<div key={index}>
<select
value={item.product_id}
onChange={e => updateItem(index, 'product_id', e.target.value)}
>
<option value="">Pilih produk</option>
{products.map(product => (
<option key={product.id} value={product.id}>
{product.name}
</option>
))}
</select>
{form.errors[`items.${index}.product_id`] && (
<div>{form.errors[`items.${index}.product_id`]}</div>
)}
<input
type="number"
min="1"
value={item.qty}
onChange={e => updateItem(index, 'qty', e.target.value)}
/>
{form.errors[`items.${index}.qty`] && (
<div>{form.errors[`items.${index}.qty`]}</div>
)}
<input
type="number"
step="0.01"
value={item.price}
onChange={e => updateItem(index, 'price', e.target.value)}
/>
{form.errors[`items.${index}.price`] && (
<div>{form.errors[`items.${index}.price`]}</div>
)}
<label>
<input
type="checkbox"
checked={item.taxable}
onChange={e => updateItem(index, 'taxable', e.target.checked)}
/>
Kena pajak
</label>
<button type="button" onClick={() => removeItem(index)}>
Hapus
</button>
</div>
))}
<button type="button" onClick={addItem}>Tambah Item</button>
<button type="submit" disabled={form.processing}>Simpan</button>
</form>Pola ini bekerja baik untuk banyak kebutuhan karena frontend dan backend sama-sama memahami bahwa items adalah array objek.
Transform Payload Sebelum Submit
Tidak semua data di form sebaiknya dikirim mentah. Sering kali input dari browser berupa string, padahal backend mengharapkan integer, decimal, atau boolean. Selain itu, mungkin ada field UI-only yang tidak perlu dikirim ke server.
Di sinilah transform() pada useForm sangat berguna. Dengan transformasi, kita bisa menormalisasi payload sebelum request dikirim.
const submit = (e) => {
e.preventDefault()
form
.transform((data) => ({
...data,
customer_id: data.customer_id ? Number(data.customer_id) : null,
send_email: !!data.send_email,
items: data.items
.filter(item => item.product_id || item.description)
.map(item => ({
product_id: item.product_id ? Number(item.product_id) : null,
description: item.description?.trim() || null,
qty: Number(item.qty || 0),
price: Number(item.price || 0),
taxable: !!item.taxable,
})),
}))
.post(route('invoices.store'))
}Kenapa transformasi ini penting?
- Merapikan tipe data. Input HTML hampir selalu mengembalikan string.
- Membuang item kosong. Sangat umum user menambah baris lalu tidak mengisinya.
- Mencegah payload kotor. Field yang hanya dipakai untuk tampilan tidak ikut dikirim.
Jika Anda menggunakan checkbox dinamis, pastikan nilainya benar-benar boolean. Kesalahan umum adalah mengirim string
"on"atau"false"yang dapat menghasilkan perilaku validasi atau casting yang tidak konsisten.
Validasi Nested Field di Laravel
Setelah payload dikirim, Laravel perlu memvalidasi struktur data secara detail. Untuk kasus invoice, validasi nested biasanya ditulis menggunakan wildcard *.
public function store(Request $request)
{
$validated = $request->validate([
'customer_id' => ['required', 'exists:customers,id'],
'invoice_date' => ['required', 'date'],
'due_date' => ['nullable', 'date', 'after_or_equal:invoice_date'],
'notes' => ['nullable', 'string'],
'send_email' => ['boolean'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'exists:products,id'],
'items.*.description' => ['nullable', 'string'],
'items.*.qty' => ['required', 'numeric', 'min:1'],
'items.*.price' => ['required', 'numeric', 'min:0'],
'items.*.taxable' => ['boolean'],
]);
// simpan data...
}Aturan di atas penting karena:
itemsdipastikan berupa array dan minimal memiliki satu elemen.- Setiap item harus memiliki
product_id,qty, danpriceyang valid. - Validasi otomatis akan menghasilkan key error seperti
items.0.qty,items.1.product_id, dan seterusnya.
Key error tersebut akan dikembalikan Inertia ke frontend dan bisa langsung ditampilkan per item seperti contoh sebelumnya. Ini salah satu alasan mengapa menjaga bentuk nama field tetap konsisten sangat membantu.
Menggunakan Form Request untuk Kode yang Lebih Rapi
Untuk form kompleks, lebih baik pindahkan validasi ke FormRequest agar controller tetap fokus pada proses bisnis.
class StoreInvoiceRequest extends FormRequest
{
public function rules(): array
{
return [
'customer_id' => ['required', 'exists:customers,id'],
'invoice_date' => ['required', 'date'],
'due_date' => ['nullable', 'date', 'after_or_equal:invoice_date'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'exists:products,id'],
'items.*.qty' => ['required', 'numeric', 'min:1'],
'items.*.price' => ['required', 'numeric', 'min:0'],
'items.*.taxable' => ['boolean'],
];
}
}Keuntungan pendekatan ini adalah validasi lebih mudah diuji, digunakan ulang, dan dibaca ketika form tumbuh semakin kompleks.
Menyimpan Relasi hasMany dengan Aman
Setelah validasi lolos, tahap berikutnya adalah menyimpan invoice dan item-itemnya. Pola umum di Laravel adalah membuat parent lebih dulu, lalu membuat child melalui relasi hasMany. Gunakan transaksi database agar data tidak setengah tersimpan jika terjadi error.
use Illuminate\Support\Facades\DB;
public function store(StoreInvoiceRequest $request)
{
DB::transaction(function () use ($request) {
$invoice = Invoice::create([
'customer_id' => $request->customer_id,
'invoice_date' => $request->invoice_date,
'due_date' => $request->due_date,
'notes' => $request->notes,
'send_email' => $request->boolean('send_email'),
]);
$items = collect($request->items)->map(function ($item) {
return [
'product_id' => $item['product_id'],
'description' => $item['description'] ?? null,
'qty' => $item['qty'],
'price' => $item['price'],
'taxable' => $item['taxable'] ?? false,
];
});
$invoice->items()->createMany($items->all());
});
return redirect()
->route('invoices.index')
->with('success', 'Invoice berhasil disimpan.');
}Mengapa transaksi penting? Jika invoice berhasil dibuat tetapi salah satu item gagal disimpan, database akan masuk ke keadaan tidak konsisten. Dengan DB::transaction(), seluruh operasi dibatalkan jika ada exception.
Kasus Update Data hasMany
Pada form edit, masalahnya sedikit lebih rumit karena item lama bisa diperbarui, ditambah, atau dihapus. Pendekatan umum adalah mengirim id untuk item yang sudah ada, lalu backend melakukan sinkronisasi manual: update item lama, buat item baru, dan hapus item yang tidak lagi dikirim. Ini lebih aman daripada menghapus semua item lalu membuat ulang jika ada kebutuhan audit, relasi lanjutan, atau trigger tertentu.
Menampilkan Error Per Item dengan Jelas
Salah satu kekuatan Inertia adalah error validasi dari Laravel dapat diakses langsung melalui form.errors. Untuk nested field, key error mengikuti format titik, misalnya items.2.qty. Di React, Vue, atau Svelte, Anda bisa membangun helper kecil agar akses error lebih rapi.
const itemError = (index, field) => form.errors[`items.${index}.${field}`]Lalu gunakan helper itu pada rendering input:
{itemError(index, 'qty') && <div className="text-red-600">{itemError(index, 'qty')}</div>}Pola ini lebih bersih dibanding menulis string template yang sama berkali-kali.
Perlu diingat, saat menghapus item dari tengah array, indeks item setelahnya akan bergeser. Ini normal, tetapi bisa membuat error yang lama terlihat “berpindah” saat state belum sinkron penuh. Biasanya masalah ini hilang setelah submit ulang. Jika form sangat interaktif, pertimbangkan menggunakan uuid lokal untuk key rendering, namun tetap kirim array biasa ke backend agar validasi wildcard Laravel tetap sederhana.
Praktik Baik, Trade-off, dan Debugging
1. Simpan struktur data sedekat mungkin dengan backend
Jika backend mengharapkan items sebagai array objek, jangan ubah menjadi struktur aneh seperti object keyed by id hanya karena lebih mudah di-render. Semakin jauh struktur frontend dari backend, semakin besar kebutuhan transformasi dan potensi bug.
2. Jangan terlalu cepat melakukan normalisasi saat user mengetik
Untuk field angka, sering lebih nyaman membiarkan nilai tetap sebagai string selama user mengedit, lalu ubah ke number saat submit. Ini menghindari masalah input seperti 1. atau string kosong yang sulit ditangani jika dipaksa langsung menjadi number.
3. Gunakan computed total di frontend, tetapi jangan percaya sepenuhnya
Anda boleh menghitung subtotal, pajak, dan grand total di frontend untuk UX yang lebih baik. Namun, backend tetap sebaiknya menghitung ulang atau memverifikasi nilai penting agar tidak bergantung pada input dari client.
4. Periksa payload di browser
Jika validasi terasa tidak masuk akal, buka tab Network di DevTools dan lihat payload request yang benar-benar terkirim. Sering kali sumber masalahnya sederhana: checkbox tidak terkirim, angka masih string kosong, atau item kosong belum difilter.
5. Waspadai field opsional pada nested data
Field seperti description atau taxable mungkin tidak selalu ada. Di backend, gunakan default yang eksplisit atau helper seperti $request->boolean() agar perilaku lebih konsisten.
Kesalahan umum pada form kompleks bukan terletak pada submit-nya, melainkan pada ketidakkonsistenan bentuk data antara state frontend, payload request, aturan validasi, dan proses penyimpanan relasi.
Penutup
Mengelola form kompleks di Inertia.js dengan useForm pada dasarnya adalah soal disiplin terhadap struktur data. Jika Anda memulai dengan bentuk state yang jelas, melakukan transform() sebelum submit, memvalidasi nested field dengan benar di Laravel, dan menampilkan error per item secara spesifik, maka form invoice, order, atau dokumen bisnis lain akan jauh lebih stabil.
Untuk aplikasi nyata, kombinasi ini sangat efektif: state frontend yang konsisten, payload yang dinormalisasi, validasi wildcard Laravel, dan penyimpanan relasi hasMany di dalam transaksi. Hasilnya bukan hanya form yang berjalan, tetapi alur data yang lebih mudah dipelihara, diuji, dan dikembangkan ketika kebutuhan bisnis bertambah.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!