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:

  1. Add debouncing to avoid spamming the server with requests for text input.
  2. Make it reusable so we don’t repeat this logic in every component.
  3. Sync URL params on page load.
  4. 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!