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))
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
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>
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
}
)
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
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
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,
]);
}
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 ✓
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)
},
}
)
Install
npm install @mits_pl/use-inertia-filters
Peer deps: vue >= 3.3, @inertiajs/vue3 >= 1.0. No other dependencies.
Links:
- npm: npmjs.com/package/@mits_pl/use-inertia-filters
- Built by MITS — a software house specializing in Laravel + Vue + Inertia
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)