DEV Community

Paweł Targosinski
Paweł Targosinski

Posted on

Stop writing the same filter boilerplate in every Inertia.js project

Every Laravel + Inertia app has a list page. Every list page has filters. And every time, you write the same thing:

const search = ref('')
const status = ref(null)
const perPage = ref(15)

watch([search, status, perPage], debounce(() => {
  router.get('/users', pickBy({ search: search.value, status: status.value, perPage: perPage.value }), {
    preserveState: true,
    replace: true,
  })
}, 300))
Enter fullscreen mode Exit fullscreen mode

Then you realize pickBy doesn't handle false correctly. Then you add URL hydration on mount. Then you add a reset button. Then you need instant visits for perPage but debounced for search. Then you paste the whole thing into the next project.

I got tired of this. So I extracted it into a composable and published it.


Introducing useInertiaFilters

npm install @mits_pl/use-inertia-filters
Enter fullscreen mode Exit fullscreen mode

The same page, now:

<script setup lang="ts">
import { useInertiaFilters } from '@mits_pl/use-inertia-filters'

const { filters, reset, isDirty } = useInertiaFilters({
  search: '',
  status: null,
  perPage: 15,
})
</script>

<template>
  <input v-model="filters.search" placeholder="Search..." />

  <select v-model="filters.status">
    <option :value="null">All</option>
    <option value="active">Active</option>
    <option value="inactive">Inactive</option>
  </select>

  <select v-model="filters.perPage">
    <option :value="15">15</option>
    <option :value="25">25</option>
    <option :value="50">50</option>
  </select>

  <button v-if="isDirty()" @click="reset">Clear filters</button>
</template>
Enter fullscreen mode Exit fullscreen mode

That's the whole thing. Changes are debounced, the URL is updated, state is preserved.


What it actually does

1. URL hydration on mount

If a user lands on /users?search=john&perPage=25, the composable reads those query params and sets the filter state — with proper type casting. "25" becomes 25 (number), "true" becomes true (boolean). Types are inferred from your initial values.

2. Clean URLs

Only non-empty, non-default values end up in the URL. No more /users?search=&status=&perPage=15 — just /users?search=john.

3. Debounce by default, instant when you need it

The debounceOnly option lets you decide per-key:

const { filters } = useInertiaFilters(
  { search: '', sort: 'name', perPage: 15 },
  {
    debounce: 400,
    debounceOnly: ['search'], // only search is debounced
    // sort and perPage fire immediately
  }
)
Enter fullscreen mode Exit fullscreen mode

4. Full reset and dirty state

const { filters, reset, resetKeys, isDirty, isKeyDirty } = useInertiaFilters({
  search: '',
  status: null,
})

isDirty()           // true if anything changed
isKeyDirty('search') // true if search specifically changed
resetKeys('search') // reset only search, keep status
reset()             // reset everything
Enter fullscreen mode Exit fullscreen mode

5. Escape hatch: flush()

Bypass the debounce and trigger an immediate visit:

const { flush } = useInertiaFilters({ search: '' })

// somewhere in your code
flush() // fires right now, no waiting
Enter fullscreen mode Exit fullscreen mode

The backend stays simple

No Spatie Query Builder required. Just standard Laravel:

public function index(Request $request): Response
{
    $users = User::query()
        ->when($request->search, fn ($q, $v) => $q->where('name', 'like', "%{$v}%"))
        ->when($request->status, fn ($q, $v) => $q->where('status', $v))
        ->paginate($request->perPage ?? 15)
        ->withQueryString();

    return Inertia::render('Users/Index', [
        'users' => $users,
    ]);
}
Enter fullscreen mode Exit fullscreen mode

Why not use existing table packages?

Packages like inertiajs-tables-laravel-query-builder solve a different problem — they give you a full DataTable component with UI. That comes with requirements: Spatie QueryBuilder on the backend, Tailwind CSS Forms plugin, specific Laravel version.

useInertiaFilters is headless. No UI. No backend requirements. No CSS. It works with shadcn-vue, PrimeVue, Vuetify, Quasar, or plain HTML. You bring your own table, your own inputs — the composable just handles the state and the routing.


TypeScript

Full inference, no generics needed:

const { filters, isKeyDirty } = useInertiaFilters({
  search: '',
  perPage: 15,
})

filters.search   // inferred as string ✓
filters.perPage  // inferred as number ✓
filters.nope     // TypeScript error ✓

isKeyDirty('search')  // ✓
isKeyDirty('nope')    // TypeScript error ✓
Enter fullscreen mode Exit fullscreen mode

Hooks for advanced cases

useInertiaFilters(
  { search: '', status: null },
  {
    onBeforeVisit: (filters) => {
      // return false to cancel the visit
      if (!filters.search && !filters.status) return false
    },
    onAfterVisit: (filters) => {
      analytics.track('filters_applied', filters)
    },
  }
)
Enter fullscreen mode Exit fullscreen mode

Install

npm install @mits_pl/use-inertia-filters
Enter fullscreen mode Exit fullscreen mode

Peer deps: vue >= 3.3, @inertiajs/vue3 >= 1.0. No other dependencies.

Links:


We use this internally on every project we build. If it helps you too, give it a star. Issues and PRs welcome.

Top comments (0)