Form kompleks adalah salah satu area yang paling cepat membuat komponen Livewire menjadi sulit dirawat. Begitu kebutuhan bertambah—misalnya validasi real-time, form bertahap, field dinamis, upload file, dan struktur data bertingkat—state komponen bisa membesar, aturan validasi menyebar, dan interaksi pengguna terasa lambat jika tidak dirancang dengan baik.

Di Livewire 4, pendekatan membangun form seperti ini menjadi lebih nyaman karena pola komponen, binding state, validasi, dan pengelolaan interaksi UI semakin cocok untuk kasus aplikasi bisnis yang nyata. Meski begitu, prinsip dasarnya tetap sama: batasi state, validasi sedekat mungkin dengan data yang relevan, dan pecah form besar menjadi unit yang lebih kecil bila kompleksitas mulai meningkat.

Artikel ini membahas pendekatan praktis untuk membangun form kompleks di Livewire 4, termasuk validasi real-time, wizard multi-step, dynamic fields, nested data, upload file, serta tips menjaga performa dan pengalaman pengguna.

Mengapa Form Kompleks di Livewire Perlu Desain yang Disengaja

Secara default, Livewire membuat kita mudah mengikat input ke properti komponen. Masalahnya, kemudahan ini sering membuat semua state ditaruh dalam satu komponen besar. Pada awalnya terlihat sederhana, tetapi lama-lama muncul gejala berikut:

  • State terlalu besar, sehingga setiap perubahan memicu sinkronisasi data yang lebih berat.
  • Validasi sulit dipahami, terutama jika field bertingkat atau dinamis.
  • Wizard sulit dikontrol, karena logika perpindahan step bercampur dengan penyimpanan data.
  • Upload file rawan error, terutama bila digabung dengan reset state yang agresif.
  • Komponen sulit diuji, karena semua tanggung jawab berada di satu tempat.

Pendekatan yang lebih sehat adalah memandang form sebagai state machine kecil: ada state aktif, aturan validasi per bagian, dan transisi yang jelas. Livewire sangat cocok untuk model ini selama kita disiplin dalam memisahkan tanggung jawab.

Mendesain State Form yang Mudah Dirawat

Gunakan satu struktur data utama untuk form

Alih-alih membuat banyak properti terpisah seperti $name, $email, $phone, $addresses, lebih baik kelompokkan dalam satu array atau objek form yang konsisten. Ini memudahkan validasi, serialisasi, dan reset sebagian data.

<?php

namespace App\Livewire\Customers;

use Livewire\Component;
use Livewire\WithFileUploads;

class CustomerForm extends Component
{
    use WithFileUploads;

    public array $form = [
        'name' => '',
        'email' => '',
        'phone' => '',
        'company' => [
            'name' => '',
            'npwp' => '',
        ],
        'addresses' => [
            ['label' => 'utama', 'street' => '', 'city' => ''],
        ],
        'documents' => [],
    ];

    public int $step = 1;

    public function render()
    {
        return view('livewire.customers.customer-form');
    }
}

Keuntungan struktur seperti ini:

  • Semua data form berada dalam satu namespace yang jelas.
  • Rule validasi menjadi lebih konsisten, misalnya form.company.name atau form.addresses.*.city.
  • Lebih mudah memetakan data ke DTO, action class, atau model saat proses simpan.

Kapan perlu memecah ke beberapa komponen

Jika setiap step wizard memiliki logika yang signifikan, atau ada bagian yang independen seperti daftar alamat, upload dokumen, dan preferensi pengguna, pertimbangkan memecahnya ke child component. Ini membantu karena:

  • render menjadi lebih lokal,
  • state lebih kecil per komponen,
  • pengujian lebih fokus.

Namun ada trade-off: komunikasi antar-komponen perlu dirancang, baik melalui event, binding properti, atau penyimpanan state di parent. Untuk form yang belum terlalu besar, satu komponen utama masih cukup efektif.

Validasi Real-Time yang Tetap Masuk Akal

Validasi per field dan per step

Pada form kompleks, validasi penuh setiap kali pengguna mengetik biasanya terlalu agresif. Lebih baik gunakan validasi real-time untuk field tertentu yang memang butuh umpan balik cepat, misalnya email unik, format nomor telepon, atau panjang input. Untuk validasi berat, lakukan saat pindah step atau submit akhir.

<?php

namespace App\Livewire\Customers;

use Illuminate\Validation\Rule;
use Livewire\Component;

class CustomerForm extends Component
{
    public array $form = [
        'name' => '',
        'email' => '',
        'phone' => '',
        'company' => ['name' => '', 'npwp' => ''],
        'addresses' => [
            ['label' => 'utama', 'street' => '', 'city' => ''],
        ],
    ];

    public int $step = 1;

    protected function rules(): array
    {
        return [
            'form.name' => ['required', 'string', 'min:3'],
            'form.email' => ['required', 'email', Rule::unique('customers', 'email')],
            'form.phone' => ['nullable', 'string', 'max:30'],
            'form.company.name' => ['nullable', 'string', 'max:255'],
            'form.company.npwp' => ['nullable', 'string', 'max:50'],
            'form.addresses.*.label' => ['required', 'string', 'max:50'],
            'form.addresses.*.street' => ['required', 'string', 'max:255'],
            'form.addresses.*.city' => ['required', 'string', 'max:100'],
        ];
    }

    public function updated($property): void
    {
        if (in_array($property, ['form.name', 'form.email', 'form.phone'])) {
            $this->validateOnly($property);
        }
    }
}

Pola ini bekerja karena Livewire dapat memvalidasi hanya properti yang berubah. Beban validasi lebih kecil dibanding memanggil validate() untuk seluruh form setiap ketikan.

Validasi berdasarkan step aktif

Wizard sebaiknya memiliki rule per step. Ini menghindari situasi ketika pengguna di step 1 sudah terkena error dari field di step 3.

<?php

public function stepRules(): array
{
    return match ($this->step) {
        1 => [
            'form.name' => ['required', 'string', 'min:3'],
            'form.email' => ['required', 'email'],
        ],
        2 => [
            'form.company.name' => ['required', 'string', 'max:255'],
            'form.company.npwp' => ['nullable', 'string', 'max:50'],
        ],
        3 => [
            'form.addresses.*.label' => ['required', 'string'],
            'form.addresses.*.street' => ['required', 'string'],
            'form.addresses.*.city' => ['required', 'string'],
        ],
        default => [],
    };
}

public function nextStep(): void
{
    $this->validate($this->stepRules());
    $this->step++;
}

public function previousStep(): void
{
    $this->step = max(1, $this->step - 1);
}

Catatan: validasi per step lebih baik untuk UX, tetapi tetap jalankan validasi penuh saat submit akhir agar data konsisten jika ada perubahan state yang lolos di tengah alur.

Membangun Wizard Multi-Step yang Stabil

Jangan campur logika navigasi dengan persistensi

Kesalahan umum adalah langsung menyimpan ke database saat pindah step. Untuk beberapa kasus ini masuk akal, tetapi sering kali justru menambah kompleksitas: perlu status draft, rollback parsial, dan penanganan data setengah lengkap. Jika belum benar-benar dibutuhkan, simpan semua dalam state komponen sampai submit final.

Contoh tampilan wizard sederhana:

<div>
    @if ($step === 1)
        <div>
            <input type="text" wire:model.blur="form.name" placeholder="Nama">
            @error('form.name') <span>{{ $message }}</span> @enderror

            <input type="email" wire:model.blur="form.email" placeholder="Email">
            @error('form.email') <span>{{ $message }}</span> @enderror
        </div>
    @endif

    @if ($step === 2)
        <div>
            <input type="text" wire:model.blur="form.company.name" placeholder="Nama perusahaan">
            @error('form.company.name') <span>{{ $message }}</span> @enderror
        </div>
    @endif

    @if ($step === 3)
        <!-- alamat -->
    @endif

    <div class="mt-4">
        @if ($step > 1)
            <button type="button" wire:click="previousStep">Kembali</button>
        @endif

        @if ($step < 3)
            <button type="button" wire:click="nextStep">Lanjut</button>
        @else
            <button type="button" wire:click="save">Simpan</button>
        @endif
    </div>
</div>

Perhatikan penggunaan wire:model.blur pada beberapa field. Untuk form besar, ini sering lebih efisien daripada sinkronisasi di setiap ketikan. Anda tetap mendapat pengalaman yang responsif, tetapi jumlah request berkurang.

Simpan draft bila proses panjang

Jika wizard panjang atau berisiko ditinggal pengguna, pertimbangkan fitur draft. Simpan snapshot state ke database atau cache secara eksplisit, misalnya saat pengguna menekan tombol “Simpan Draft” atau ketika berpindah step tertentu. Hindari autosave terlalu sering tanpa throttle karena dapat menambah beban server dan membuat race condition.

Dynamic Fields dan Nested Data

Menambah dan menghapus field berulang

Kasus umum pada form bisnis adalah daftar alamat, nomor kontak, item invoice, atau dokumen pendukung. Dynamic fields paling mudah dikelola jika setiap item mempunyai struktur yang konsisten.

<?php

public function addAddress(): void
{
    $this->form['addresses'][] = [
        'label' => '',
        'street' => '',
        'city' => '',
    ];
}

public function removeAddress(int $index): void
{
    unset($this->form['addresses'][$index]);
    $this->form['addresses'] = array_values($this->form['addresses']);
}

Pada Blade:

@foreach ($form['addresses'] as $index => $address)
    <div wire:key="address-{{ $index }}">
        <input type="text" wire:model.blur="form.addresses.{{ $index }}.label" placeholder="Label">
        <input type="text" wire:model.blur="form.addresses.{{ $index }}.street" placeholder="Jalan">
        <input type="text" wire:model.blur="form.addresses.{{ $index }}.city" placeholder="Kota">

        <button type="button" wire:click="removeAddress({{ $index }})">Hapus</button>
    </div>
@endforeach

<button type="button" wire:click="addAddress">Tambah alamat</button>

Ada dua hal penting di sini:

  • Gunakan wire:key agar Livewire dapat melacak elemen berulang dengan benar.
  • Reindex array setelah hapus item jika Anda memakai indeks numerik. Tanpa ini, binding dan validasi bisa menunjuk ke indeks yang sudah tidak sinkron.

Kapan pakai ID stabil, bukan indeks array

Jika daftar item dapat sering diubah urutannya atau memiliki interaksi kompleks, lebih aman menggunakan identifier stabil per item, misalnya UUID lokal. Ini membantu mencegah bug UI ketika elemen berpindah posisi tetapi masih dianggap item yang sama oleh DOM diffing.

Upload File dalam Form Kompleks

Upload file sering menjadi bagian paling sensitif karena ada sinkronisasi state, validasi, dan penyimpanan fisik. Dengan Livewire, gunakan trait upload yang sesuai dan validasi file sebelum dipindahkan ke storage permanen.

<?php

use Livewire\WithFileUploads;

class CustomerForm extends Component
{
    use WithFileUploads;

    public array $form = [
        'documents' => [],
    ];

    protected function rules(): array
    {
        return [
            'form.documents.*' => ['file', 'mimes:pdf,jpg,jpeg,png', 'max:2048'],
        ];
    }

    public function save(): void
    {
        $validated = $this->validate(array_merge($this->rules(), [
            'form.name' => ['required', 'string', 'min:3'],
            'form.email' => ['required', 'email'],
        ]));

        $paths = [];

        foreach ($this->form['documents'] as $file) {
            $paths[] = $file->store('customer-documents', 'public');
        }

        // simpan data customer dan path file ke database
    }
}

Beberapa praktik penting:

  • Validasi ukuran dan tipe file di server, bukan hanya di UI.
  • Jangan langsung percaya nama file asli dari klien.
  • Jika submit akhir gagal setelah sebagian file tersimpan, siapkan strategi cleanup atau gunakan penyimpanan draft sementara.

Untuk UX, tampilkan progress upload dan status file yang berhasil dipilih. Ini penting terutama jika wizard memiliki step upload tersendiri.

Menjaga Performa pada State Form Besar

Pilih strategi binding yang tepat

Tidak semua field perlu sinkron ke server pada setiap ketikan. Untuk banyak kasus:

  • wire:model: cocok untuk interaksi yang memang harus instan.
  • wire:model.blur: pilihan aman untuk mayoritas input teks pada form panjang.
  • wire:model.defer: cocok jika data baru benar-benar dipakai saat submit atau aksi tertentu.

Semakin besar state form, semakin penting memilih strategi binding dengan sadar. Mengikat puluhan field ke update instan biasanya bukan ide bagus.

Kurangi render yang tidak perlu

Jika satu bagian form berubah, usahakan bagian lain tidak ikut kompleks dalam render. Beberapa tips praktis:

  • Pecah bagian berat menjadi child component.
  • Hindari komputasi mahal langsung di method render().
  • Preload data referensi seperlunya, misalnya daftar kota atau kategori, lalu cache jika masuk akal.
  • Gunakan pagination atau pencarian async untuk dropdown data besar, bukan memuat semuanya ke form.

Jangan bawa seluruh model ke state

Kesalahan umum adalah menaruh instance model Eloquent yang besar beserta relasinya langsung ke properti komponen. Ini membuat payload membengkak dan perilaku state sulit diprediksi. Lebih aman petakan hanya field yang memang dibutuhkan ke array $form.

Refactor dari Pola Lama ke Pendekatan yang Lebih Rapi

Pada pola lama, sering terlihat komponen seperti ini:

  • banyak properti publik individual,
  • method save() sangat panjang,
  • validasi tersebar di berbagai tempat,
  • logika transformasi data bercampur dengan UI.

Refactor yang disarankan:

  1. Gabungkan field ke satu struktur form agar validasi dan reset lebih konsisten.
  2. Pisahkan rule per step jika memakai wizard.
  3. Pindahkan persistensi ke action class atau service bila proses simpan mulai kompleks.
  4. Gunakan child component untuk blok independen seperti alamat, item invoice, atau upload dokumen.
  5. Buat mapper eksplisit dari state form ke model/database, jangan andalkan assignment yang tidak jelas.

Contoh sederhana pemisahan persistensi:

<?php

namespace App\Actions\Customers;

use App\Models\Customer;

class CreateCustomer
{
    public function handle(array $data, array $documentPaths = []): Customer
    {
        $customer = Customer::create([
            'name' => $data['name'],
            'email' => $data['email'],
            'phone' => $data['phone'] ?? null,
        ]);

        // simpan relasi company, addresses, documents

        return $customer;
    }
}

Dengan pola ini, komponen Livewire fokus pada interaksi UI dan validasi, sedangkan detail penyimpanan data dipindahkan ke lapisan yang lebih mudah diuji.

Masalah Umum dan Tips Debugging

Error validasi tidak muncul di field yang benar

Biasanya disebabkan path properti tidak cocok dengan rule, terutama pada nested array. Pastikan nama binding di Blade persis sama dengan key validasi.

Dynamic fields berperilaku aneh setelah item dihapus

Sering terjadi karena wire:key tidak stabil atau array tidak diindex ulang. Jika perlu, gunakan ID unik per item, bukan sekadar indeks.

Upload file hilang setelah pindah step

Periksa apakah ada reset state yang terlalu luas, misalnya memanggil reset pada seluruh $form. Reset hanya bagian yang benar-benar diperlukan.

Form terasa lambat

Cek apakah terlalu banyak field menggunakan sinkronisasi instan. Ubah ke blur atau defer, lalu ukur kembali. Juga pastikan tidak ada query database berat di setiap render.

Penutup

Livewire 4 sangat cocok untuk form kompleks selama state dan validasi dirancang dengan disiplin. Kunci utamanya adalah menggunakan struktur form yang konsisten, menerapkan validasi real-time secara selektif, memisahkan rule per step pada wizard, mengelola dynamic fields dengan key yang stabil, dan menangani upload file dengan validasi serta lifecycle yang jelas.

Jika form mulai tumbuh besar, jangan ragu memecahnya menjadi beberapa komponen atau memindahkan logika penyimpanan ke action class. Dengan begitu, Anda tidak hanya mendapatkan UX yang lebih baik, tetapi juga basis kode yang jauh lebih mudah dirawat, diuji, dan dikembangkan.