Membangun tabel data yang nyaman dipakai hampir selalu melibatkan empat kebutuhan yang sama: pagination, filter, sorting, dan pencarian. Pada aplikasi Laravel dengan Inertia.js, kebutuhan ini bisa diimplementasikan dengan pola yang rapi tanpa harus membangun API terpisah untuk front-end.

Dalam artikel ini, kita akan membuat contoh daftar user dengan fitur berikut:

  • Paginasi berbasis query string
  • Filter status, misalnya active atau inactive
  • Sorting berdasarkan nama atau tanggal dibuat
  • Pencarian keyword pada nama dan email
  • State filter tetap terjaga saat pindah halaman
  • Debounce input pencarian agar request tidak berlebihan
  • Partial reload Inertia untuk mengurangi payload yang dikirim

Pendekatan ini cocok untuk halaman admin, dashboard internal, atau daftar order/user yang membutuhkan interaksi cepat tetapi tetap sederhana dari sisi arsitektur.

Mengapa pola ini efektif pada Laravel + Inertia.js?

Inertia.js bekerja sebagai jembatan antara back-end dan front-end. Laravel tetap bertanggung jawab menyusun data, memproses query database, dan mengirimkan props ke komponen front-end. Di sisi lain, komponen front-end dapat mengubah query string dan meminta data baru tanpa memuat ulang halaman penuh.

Keuntungan utamanya:

  • Logika query tetap di server, sehingga validasi parameter dan kontrol akses lebih mudah dikelola.
  • URL tetap merepresentasikan state halaman, misalnya ?search=budi&status=active&sort=name&direction=asc&page=2.
  • Halaman bisa dibagikan atau di-refresh tanpa kehilangan filter yang aktif.
  • Tidak perlu memisahkan API dan SSR-like page layer untuk kebutuhan CRUD admin yang umum.

Namun, ada beberapa hal yang perlu diperhatikan: parameter query harus divalidasi, sorting tidak boleh dibiarkan bebas agar aman, dan input pencarian sebaiknya di-debounce supaya server tidak menerima request pada setiap ketikan.

Struktur data dan route

Misalkan kita memiliki tabel users dengan kolom standar seperti name, email, status, dan created_at. Route yang digunakan cukup sederhana:

use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;

Route::get('/users', [UserController::class, 'index'])->name('users.index');

Halaman ini akan membaca parameter query dari request, membangun query Eloquent secara dinamis, lalu mengirim hasil pagination dan state filter ke komponen Inertia.

Implementasi controller: membaca query string dan membangun query

Controller index yang aman dan mudah diperluas

Berikut contoh implementasi controller untuk daftar user:

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;

class UserController extends Controller
{
    public function index(Request $request)
    {
        $filters = [
            'search' => $request->string('search')->toString(),
            'status' => $request->string('status')->toString(),
            'sort' => $request->string('sort')->toString() ?: 'created_at',
            'direction' => $request->string('direction')->toString() ?: 'desc',
            'per_page' => (int) $request->input('per_page', 10),
        ];

        $allowedSorts = ['name', 'email', 'created_at', 'status'];
        $allowedDirections = ['asc', 'desc'];
        $allowedStatuses = ['active', 'inactive'];
        $allowedPerPage = [10, 25, 50, 100];

        if (! in_array($filters['sort'], $allowedSorts, true)) {
            $filters['sort'] = 'created_at';
        }

        if (! in_array($filters['direction'], $allowedDirections, true)) {
            $filters['direction'] = 'desc';
        }

        if (! in_array($filters['status'], $allowedStatuses, true)) {
            $filters['status'] = '';
        }

        if (! in_array($filters['per_page'], $allowedPerPage, true)) {
            $filters['per_page'] = 10;
        }

        $users = User::query()
            ->when($filters['search'], function ($query, $search) {
                $query->where(function ($q) use ($search) {
                    $q->where('name', 'like', "%{$search}%")
                      ->orWhere('email', 'like', "%{$search}%");
                });
            })
            ->when($filters['status'], function ($query, $status) {
                $query->where('status', $status);
            })
            ->orderBy($filters['sort'], $filters['direction'])
            ->paginate($filters['per_page'])
            ->withQueryString();

        return Inertia::render('Users/Index', [
            'users' => $users,
            'filters' => $filters,
        ]);
    }
}

Mengapa withQueryString() penting?

Tanpa withQueryString(), link pagination yang dihasilkan Laravel hanya akan membawa parameter halaman, misalnya ?page=2. Akibatnya, saat user pindah halaman, filter seperti search, status, atau sort hilang.

Dengan withQueryString(), link pagination akan mempertahankan seluruh query string aktif. Ini adalah fondasi utama agar state filter tetap konsisten ketika pengguna menavigasi halaman.

Validasi query parameter bukan sekadar formalitas

Kesalahan umum adalah langsung memakai nilai sort dan direction dari request untuk orderBy(). Itu berisiko menyebabkan query error atau membuka celah jika nama kolom tidak dibatasi. Karena itu, selalu gunakan allow-list untuk kolom sorting dan arah sorting.

Catatan: untuk sistem yang lebih besar, Anda bisa memindahkan validasi ini ke Form Request agar controller lebih bersih. Tetapi untuk halaman list sederhana, pendekatan di atas masih cukup jelas dan praktis.

Komponen Inertia: form filter, sorting, dan pagination

Contoh komponen Vue dengan state query yang sinkron

Berikut contoh komponen Inertia untuk halaman daftar user. Contoh ini menggunakan Vue 3 dan router Inertia untuk melakukan request GET ke route yang sama.

<script setup>
import { reactive, watch } from 'vue'
import { router } from '@inertiajs/vue3'

const props = defineProps({
  users: Object,
  filters: Object,
})

const form = reactive({
  search: props.filters.search || '',
  status: props.filters.status || '',
  sort: props.filters.sort || 'created_at',
  direction: props.filters.direction || 'desc',
  per_page: props.filters.per_page || 10,
})

let searchTimeout = null

watch(
  () => form.search,
  () => {
    clearTimeout(searchTimeout)
    searchTimeout = setTimeout(() => {
      reloadData()
    }, 400)
  }
)

function reloadData(page = 1) {
  router.get(route('users.index'), {
    search: form.search || undefined,
    status: form.status || undefined,
    sort: form.sort,
    direction: form.direction,
    per_page: form.per_page,
    page,
  }, {
    preserveState: true,
    replace: true,
    only: ['users', 'filters'],
  })
}

function changeStatus() {
  reloadData()
}

function changePerPage() {
  reloadData()
}

function sortBy(column) {
  if (form.sort === column) {
    form.direction = form.direction === 'asc' ? 'desc' : 'asc'
  } else {
    form.sort = column
    form.direction = 'asc'
  }

  reloadData()
}

function visitPage(url) {
  if (!url) return

  router.visit(url, {
    preserveState: true,
    preserveScroll: true,
    only: ['users', 'filters'],
  })
}
</script>

<template>
  <div>
    <div class="filters" style="display:flex; gap:12px; margin-bottom:16px;">
      <input
        v-model="form.search"
        type="text"
        placeholder="Cari nama atau email"
      />

      <select v-model="form.status" @change="changeStatus">
        <option value="">Semua status</option>
        <option value="active">Active</option>
        <option value="inactive">Inactive</option>
      </select>

      <select v-model="form.per_page" @change="changePerPage">
        <option :value="10">10</option>
        <option :value="25">25</option>
        <option :value="50">50</option>
        <option :value="100">100</option>
      </select>
    </div>

    <table width="100%" border="1" cellspacing="0" cellpadding="8">
      <thead>
        <tr>
          <th @click="sortBy('name')" style="cursor:pointer;">Nama</th>
          <th @click="sortBy('email')" style="cursor:pointer;">Email</th>
          <th @click="sortBy('status')" style="cursor:pointer;">Status</th>
          <th @click="sortBy('created_at')" style="cursor:pointer;">Dibuat</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="user in users.data" :key="user.id">
          <td>{{ user.name }}</td>
          <td>{{ user.email }}</td>
          <td>{{ user.status }}</td>
          <td>{{ user.created_at }}</td>
        </tr>
        <tr v-if="users.data.length === 0">
          <td colspan="4">Tidak ada data.</td>
        </tr>
      </tbody>
    </table>

    <div style="display:flex; gap:8px; margin-top:16px; flex-wrap:wrap;">
      <button
        v-for="link in users.links"
        :key="link.label"
        :disabled="!link.url"
        @click="visitPage(link.url)"
        v-html="link.label"
      />
    </div>
  </div>
</template>

Mengapa request memakai router.get() ke halaman yang sama?

Karena semua state daftar disimpan pada query string. Saat nilai filter berubah, kita cukup mengirim request GET baru ke route yang sama dengan parameter terbaru. Laravel akan membangun ulang hasil query, lalu Inertia memperbarui props tanpa reload penuh.

Pola ini jauh lebih mudah dipelihara dibanding menyebar logika query ke banyak tempat. Server tetap menjadi sumber kebenaran untuk data tabel.

Debounce pencarian agar tidak membebani server

Input pencarian yang langsung memicu request pada setiap karakter memang terasa responsif, tetapi dapat menghasilkan terlalu banyak request. Misalnya user mengetik 8 karakter dengan cepat, maka bisa terjadi 8 request berturut-turut.

Solusinya adalah debounce. Pada contoh di atas, perubahan form.search ditunda 400 ms. Jika user masih mengetik, timer sebelumnya dibatalkan, sehingga request hanya dikirim ketika user berhenti sejenak.

Trade-off-nya:

  • Delay terlalu kecil membuat request masih terlalu sering.
  • Delay terlalu besar membuat UI terasa lambat.
  • Nilai 300–500 ms biasanya cukup nyaman untuk kebanyakan form pencarian admin.

Jika Anda memakai lodash, Anda juga bisa menggunakan debounce(). Namun pendekatan manual dengan setTimeout seperti di atas sudah memadai dan tidak menambah dependensi.

Partial reload dengan Inertia untuk efisiensi

Salah satu fitur yang sering terlewat adalah partial reload. Pada contoh di atas, setiap request menggunakan opsi:

only: ['users', 'filters']

Artinya, Inertia hanya meminta prop yang relevan untuk pembaruan daftar. Jika halaman Anda memiliki prop tambahan seperti statistik, daftar role, atau data sidebar yang mahal untuk dihitung, Anda tidak perlu mengirim ulang semuanya saat user sekadar mengganti halaman pagination atau mengetik kata kunci.

Keuntungan partial reload:

  • Payload respons lebih kecil
  • Waktu serialisasi data lebih ringan
  • Komponen yang tidak terkait tidak perlu diproses ulang

Namun ada syarat penting: Anda harus memahami prop mana yang memang berubah. Jika sebuah komponen bergantung pada prop yang tidak ikut dimuat ulang, UI bisa terlihat tidak sinkron. Karena itu, gunakan only secara sengaja, bukan asal menyalin.

Mempertahankan state saat pindah halaman

Ada dua bagian yang bekerja bersama untuk menjaga state:

  1. Back-end: withQueryString() memastikan link pagination membawa filter saat ini.
  2. Front-end: nilai form diinisialisasi dari props.filters, sehingga ketika halaman diperbarui oleh Inertia, komponen tetap sinkron dengan query string terbaru.

Selain itu, penggunaan replace: true pada router.get() berguna agar riwayat browser tidak dipenuhi oleh setiap perubahan kecil, terutama saat user mengetik pada field pencarian.

Untuk navigasi pagination, kita memakai router.visit(link.url). Karena URL link hasil paginate sudah mengandung query string lengkap, kita tidak perlu membangun parameter lagi secara manual.

Praktik baik, batasan, dan debugging

Praktik baik

  • Whitelist kolom sorting untuk mencegah query tidak valid.
  • Gunakan indeks database pada kolom yang sering difilter atau diurutkan, misalnya status dan created_at.
  • Batasi pencarian wildcard jika dataset sangat besar, karena LIKE %keyword% bisa mahal.
  • Reset page ke 1 saat filter berubah, agar user tidak terjebak di halaman yang tidak lagi valid setelah hasil menyusut.

Batasan yang perlu dipahami

Pola ini sangat baik untuk daftar admin umum. Tetapi jika kebutuhan pencarian sangat kompleks, data sangat besar, atau filter sangat banyak, Anda mungkin perlu pendekatan tambahan seperti:

  • Full-text search
  • Server-side caching untuk query tertentu
  • Query builder yang dipisah ke service atau filter class
  • Infinite scroll jika UX menuntutnya

Jangan memaksa satu query controller menjadi terlalu besar. Saat logika filter mulai banyak, refactor ke class terpisah akan membuat kode lebih mudah diuji.

Tips debugging

  • Pastikan query string di URL benar-benar berubah saat filter diubah.
  • Jika state hilang saat pagination, cek apakah withQueryString() sudah dipakai.
  • Jika hasil sorting aneh, periksa apakah kolom database sesuai dengan nilai sort.
  • Jika request terlalu sering, audit implementasi debounce dan pastikan tidak ada watcher ganda.
  • Gunakan tab Network di browser untuk memastikan partial reload hanya meminta prop yang diperlukan.

Penutup

Membangun tabel data dengan pagination, filter status, sorting, dan pencarian pada Laravel + Inertia.js sebenarnya tidak rumit jika pola dasarnya tepat. Kuncinya adalah menjadikan query string sebagai sumber state, memproses seluruh filter di server, lalu membiarkan Inertia menangani transisi halaman secara halus.

Dengan kombinasi Request untuk membaca query string, withQueryString() untuk menjaga state pagination, debounce untuk pencarian, dan partial reload untuk efisiensi, Anda bisa mendapatkan halaman list yang responsif, mudah dibagikan, dan tetap sederhana untuk dipelihara.

Jika pola ini diterapkan secara konsisten, halaman daftar user, order, invoice, atau log aktivitas akan jauh lebih nyaman digunakan tanpa harus membangun arsitektur front-end yang terlalu kompleks.