Building Filters with Inertia.js and Rails: A Clean Approach
2025-06-03 08:00:41 -0300
Filtering is a fundamental feature in nearly every web application. Whether you’re dealing with product listings, user management panels, or activity logs, filters allow users to drill down into exactly what they’re looking for. Yet, despite being so common, implementing filters in a clean and standardized way can be a challenge.
In this article, we’ll walk through a full-stack approach to building filters using Inertia.js on the frontend and Rails on the backend. We’ll start simple and progressively refine the implementation into something reusable and maintainable.
Filtering on the Backend with a Filterable
Concern
Let’s start with the backend. In a typical Rails controller, filters often look like this:
def index
@users = User.where(status: params[:status])
end
This works, but it quickly gets messy as you add more filters. A better approach is to extract the logic into a concern.
Here’s a simplified version of a Filterable
concern:
# app/models/concerns/filterable.rb
module Filterable
extend ActiveSupport::Concern
included do
@filter_scopes ||= []
end
module ClassMethods
attr_reader :filter_scopes
def filter_scope(name, *args)
scope name, *args
@filter_scopes << name.to_s.gsub("filter_by_", "").to_sym
end
def filter_by(params)
results = where(nil)
params.each do |key, value|
results = results.public_send(:"filter_by_#{key}", value) if value.present?
end
results
end
end
end
Now, in your model, you can define individual filter scopes:
class User < ApplicationRecord
include Filterable
filter_scope :filter_by_status, ->(status) { where(status: status) }
filter_scope :filter_by_role, ->(role) { where(role: role) }
end
And your controller becomes much cleaner:
def index
@users = User.filter_by(params.slice(:status, :role))
end
This makes your filtering logic explicit, testable, and reusable across controllers.
The first time I saw this in action was in this blog post. Ever since, whenever I see a bunch of where filters in a controller, I refactor them using this pattern. Now, this concern is the default in all my projects.
Filtering on the Frontend: The “default” way
On the frontend, the most straightforward approach is using React’s useState
and useEffect
to manage and sync
filters. For example:
const [filters, setFilters] = useState({status: '', role: ''});
useEffect(() => {
// some condition to not load the data twice...
router.get(`/users`, filters, {preserveState: true});
}, [filters]);
This works, but it comes with problems:
- You need to manually parse and sync URL params on page load.
- You risk unnecessary requests if
useEffect
runs too often. - It becomes hard to reuse across multiple components.
- It doesn’t handle debouncing.
useEffect
can be a pain to maintain — use it only when you really have to.
A Better Way: useForm
from Inertia.js
Inertia provides a useForm
hook that simplifies form state management and integrates tightly with its request
lifecycle. Generally people only associated useForm with post/patch forms, but we can use it for GET requests too.
Here’s how to improve the above setup:
import {useForm, router} from "@inertiajs/react";
const defaultFilters = {status: '', role: ''};
const {data, setData, get} = useForm(defaultFilters);
useEffect(() => {
// some condition to not load the data twice...
if (data === defaultValue) {
return
}
get(window.location.pathname, {
preserveState: true,
preserveScroll: true,
preserveUrl: true
})
}, [data]);
This is already cleaner: useForm
handles state changes, and we only send the filters when needed. But we can still
improve in multiple fronts:
- Add debouncing to avoid spamming the server with requests for text input.
- Make it reusable so we don’t repeat this logic in every component.
- Sync URL params on page load.
- The data is requested twice on page load.
Extracting a Custom useFilters
Hook
To wrap everything together, let’s create a custom useFilters
hook that handles:
- Syncing with the URL
- Debounced and immediate updates
- Resetting to default values
Here’s the full hook implementation:
import {router, useForm} from "@inertiajs/react";
import {useEffect} from "react";
import {useRef} from "react";
function getFiltersFromURL(defaultFilters) {
const params = new URLSearchParams(window.location.search);
const filtersFromURL = {};
Object.keys(defaultFilters).forEach((key) => {
if (params.has(key)) {
filtersFromURL[key] = params.get(key);
}
});
return {...defaultFilters, ...filtersFromURL};
}
const useDebounce = () => {
const timeout = useRef();
const debounce =
(func, wait) =>
(...args) => {
clearTimeout(timeout.current);
timeout.current = setTimeout(() => func(...args), wait);
};
useEffect(() => {
return () => {
if (!timeout.current) return;
clearTimeout(timeout.current);
};
}, []);
return {debounce};
};
export function useFilters({defaultFilters, routerOptions = {}}) {
const initialFilters = getFiltersFromURL(defaultFilters);
const {debounce} = useDebounce();
const {data, setData, isDirty} = useForm(initialFilters);
const debouncedUpdateFilters = debounce((newData) => {
router.get(window.location.pathname, newData, {
preserveState: true,
preserveScroll: true,
preserveUrl: true,
...routerOptions,
});
}, 500);
const updateFilter = (key, value, options = {debounce: false}) => {
const newData = {...data, [key]: value};
setData(key, value);
options.debounce
? debouncedUpdateFilters(newData)
: router.get(window.location.pathname, newData, {
preserveState: true,
preserveScroll: true,
preserveUrl: true,
...routerOptions,
});
};
const resetFilters = () => {
setData(defaultFilters);
router.get(window.location.pathname, defaultFilters, {
preserveState: true,
preserveScroll: true,
...routerOptions,
});
};
return {data, updateFilter, resetFilters, isDirty};
}
Now, using filters in a page is as simple as:
const {data: filters, updateFilter, resetFilters} = useFilters({
defaultFilters: {status: '', role: ''},
});
-
You get full control over filter state, URL syncing, and network requests—all with a single hook.
-
The request is only trigged when change some state and not in the page load and this reduces this first request call
Why we are not using router.reload
instead router.get
?
The reason is a bug in current implementation of user reload that breaks array filters:
- https://github.com/inertiajs/inertia/issues/1709
When this issue is fixed, we can remove this workaround.
Conclusion
Filtering may seem trivial at first, but doing it well requires thoughtfulness across both backend and frontend. With this pattern, you get:
- A clear separation of concerns
- Backend filters that are readable and reusable
- Frontend filters that are synced, debounced, and composable
It’s a small investment upfront that pays off as your app scales. Happy filtering!