Every Rails app hits the same point. You’ve got a page with a couple of filters. Maybe a date range and a status dropdown. You start with params[:status] in the controller, a conditional where clause, and a default value inline. It works. Then someone asks for a second filter. Then a third. Then they want the filters to survive a page refresh. Then they want to share the URL with a colleague and have it show the same view.

Before long, your controller action is full of params[:this] || "default" and your view is stitching together query strings by hand. You know it’s messy. You just haven’t had a reason to fix it yet.

I’ve been using a pattern across a few projects now that I think is worth writing up. It uses a plain Ruby class with ActiveModel::Model and ActiveModel::Attributes to model the filter state for a page. I call it PageScope. It’s not a gem. It’s not a library. It’s about 20 lines of Ruby that sit inside your controller, and it makes filtering genuinely cleaner.

I arrived at this after getting frustrated with the same sprawl in a couple of different projects. The first time I used it, I wasn’t sure if it was over-engineering a simple problem. Three projects later, I’m fairly confident it’s not. It’s probably the pattern I’m most glad I stuck with.

I’m going to walk through it using a car sales example, because it’s easy to picture and the filtering needs are obvious.

The setup

Imagine you’re building an internal tool for a used car dealership. They’ve got a /cars page showing their current stock. The sales team wants to filter by fuel type, transmission, and price range. They want the filters to update instantly when changed, and they want to be able to send a link to a colleague that shows the same filtered view.

Here’s the model, nothing unusual:

class Car < ApplicationRecord
  scope :by_fuel, ->(fuel) { where(fuel_type: fuel) if fuel.present? }
  scope :by_transmission, ->(transmission) { where(transmission: transmission) if transmission.present? }
  scope :by_max_price, ->(price) { where("price <= ?", price) if price.present? }
  scope :available, -> { where(sold_at: nil) }
end

The messy version first

Here’s what the controller looks like when you just use params directly:

class CarsController < ApplicationController
  def index
    @cars = Car.available
    @cars = @cars.by_fuel(params[:fuel_type]) if params[:fuel_type].present?
    @cars = @cars.by_transmission(params[:transmission]) if params[:transmission].present?
    @cars = @cars.by_max_price(params[:max_price]) if params[:max_price].present?

    @fuel_type = params[:fuel_type] || ""
    @transmission = params[:transmission] || ""
    @max_price = params[:max_price] || ""
  end
end

And the view needs to manually wire up the current values:

<%= form_tag cars_path, method: :get do %>
  <%= select_tag :fuel_type,
        options_for_select(
          [["All fuels", ""], ["Petrol", "petrol"], ["Diesel", "diesel"], ["Electric", "electric"]],
          @fuel_type
        ) %>

  <%= select_tag :transmission,
        options_for_select(
          [["Any transmission", ""], ["Manual", "manual"], ["Automatic", "automatic"]],
          @transmission
        ) %>

  <%= number_field_tag :max_price, @max_price, placeholder: "Max price" %>
  <%= submit_tag "Filter" %>
<% end %>

This works. But look at what’s happening. The controller is doing type coercion, default values, and query building all in one place. The view is using select_tag and form_tag rather than the model-backed form helpers that Rails is actually good at. Every new filter means another instance variable, another conditional, another line of wiring.

It scales badly. And worse, it doesn’t feel like Rails. You’re fighting the framework rather than leaning into it.

The PageScope version

Here’s the same thing with a PageScope class. I define it as an inner class inside the controller:

class CarsController < ApplicationController
  class PageScope
    include ActiveModel::Model
    include ActiveModel::Attributes

    attribute :fuel_type, :string, default: ""
    attribute :transmission, :string, default: ""
    attribute :max_price, :integer

    def filter(cars)
      cars = cars.by_fuel(fuel_type) if fuel_type.present?
      cars = cars.by_transmission(transmission) if transmission.present?
      cars = cars.by_max_price(max_price) if max_price.present?
      cars
    end
  end

  def index
    @page_scope = PageScope.new(page_scope_params)
    @cars = @page_scope.filter(Car.available)
  end

  private

  def page_scope_params
    params.fetch(:page_scope, {}).permit(:fuel_type, :transmission, :max_price)
  end
end

The controller action is now two lines. The filtering logic lives in the PageScope class, which is a plain Ruby object. It has typed attributes with defaults, and a method that returns the filtered scope.

The view can now use form_with, which is what Rails actually wants you to use:

<%= form_with(model: @page_scope, ext_url: cars_path,
              scope: :page_scope, method: :get,
              data: { controller: "auto-submit",
                      action: "input->auto-submit#submitForm" }) do |form| %>

  <%= form.select :fuel_type,
        options_for_select(
          [["All fuels", ""], ["Petrol", "petrol"], ["Diesel", "diesel"], ["Electric", "electric"]],
          @page_scope.fuel_type
        ) %>

  <%= form.select :transmission,
        options_for_select(
          [["Any transmission", ""], ["Manual", "manual"], ["Automatic", "automatic"]],
          @page_scope.transmission
        ) %>

  <%= form.number_field :max_price, placeholder: "Max price" %>
<% end %>

A few things to notice.

form_with with method: :get means the form submits as a GET request. The filter values end up in the URL as query parameters: /cars?page_scope[fuel_type]=diesel&page_scope[transmission]=manual. Bookmarkable. Shareable. Back-button friendly.

The scope: :page_scope option nests the params under a page_scope key, which keeps them tidy and makes the permit call in the controller clean.

And the auto-submit Stimulus controller means the form submits instantly when any filter changes. No submit button needed.

The auto-submit controller

This is the Stimulus controller that makes the instant filtering work. It’s five lines:

// app/javascript/controllers/auto_submit_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  submitForm(event) {
    const { currentTarget: form } = event
    form.requestSubmit()
  }
}

That’s it. Attach it to any form, wire up input->auto-submit#submitForm on the data action, and the form submits on every change. Reusable across every filter form in the app.

Why this is better

It’s not just tidier. It changes what you can do next.

Type coercion comes free. ActiveModel::Attributes handles type casting. If you define attribute :max_price, :integer, the string "15000" from the query params becomes the integer 15000 automatically. Booleans work the same way: attribute :sold, :boolean coerces "true" and "false" from params without you writing a single line of parsing.

Defaults are declared, not scattered. attribute :fuel_type, :string, default: "" is one line. No more params[:fuel_type] || "" in the controller. No more forgetting to set a default and getting nil errors in the view.

The URL is the state. Because it’s a GET form, the filter state lives in the query string. Someone can copy the URL and send it to a colleague. The back button works. A page refresh doesn’t lose the filters. You get all of this for free from using method: :get.

Adding a filter is one attribute. Want to add a colour filter? Add attribute :colour, :string, default: "" to the PageScope, add a line to the cars method, add a select to the view. That’s it. No new instance variables. No new conditionals in the controller. No new wiring.

It’s testable. PageScope is a plain Ruby object. You can unit test it without touching the controller:

RSpec.describe CarsController::PageScope do
  it "filters by fuel type" do
    scope = described_class.new(fuel_type: "diesel")
    result = scope.filter(Car.available)
    expect(result.to_sql).to include("fuel_type")
  end

  it "defaults to showing all fuels" do
    scope = described_class.new
    expect(scope.fuel_type).to eq("")
  end

  it "coerces max_price to integer" do
    scope = described_class.new(max_price: "15000")
    expect(scope.max_price).to eq(15000)
  end
end

Where it really shines: derived values

The PageScope class can do more than just hold attributes. It can derive values from them. This is where it starts to pull away from the params-in-the-controller approach.

Say the dealership wants a “budget” filter rather than a raw price field: “Under 10k”, “10-20k”, “20-30k”, “30k+”. You could store price ranges as separate min/max params and parse them in the controller. Or you could let the PageScope handle it:

class PageScope
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :fuel_type, :string, default: ""
  attribute :transmission, :string, default: ""
  attribute :budget, :string, default: ""

  BUDGETS = {
    "under_10k" => (0..10_000),
    "10k_20k" => (10_000..20_000),
    "20k_30k" => (20_000..30_000),
    "over_30k" => (30_000..Float::INFINITY),
  }.freeze

  def price_range
    BUDGETS[budget]
  end

  def filter(cars)
    cars = cars.by_fuel(fuel_type) if fuel_type.present?
    cars = cars.by_transmission(transmission) if transmission.present?
    cars = cars.where(price: price_range) if price_range.present?
    cars
  end
end

The view just has a select with human-readable labels. The PageScope translates those into query-ready ranges. The controller doesn’t know or care about the translation. The URL reads ?page_scope[budget]=under_10k, which is clean and meaningful.

This is the pattern’s real strength. The PageScope absorbs the complexity of translating user choices into query logic. The controller stays thin. The view stays simple. The business rules live in a testable Ruby object.

One thing that caught me out the first time: if your index page links to detail pages, you probably want the filters to follow. Otherwise the user selects “diesel, manual” on the index, clicks through to a car, hits back, and their filters are gone.

Because the filters are just query params, you can pass them through:

<%= link_to car.name, car_path(car, page_scope: @page_scope.attributes.compact_blank) %>

And in the show action:

def show
  @car = Car.find(params[:id])
  @page_scope = PageScope.new(page_scope_params)
end

Now the back link from the show page can include the filters:

<%= link_to "Back to results", cars_path(page_scope: @page_scope.attributes.compact_blank) %>

The user’s filter state survives the round trip. No session storage. No JavaScript state management. Just query params.

Keeping select options on the PageScope

One thing I’ve started doing is putting the select options on the PageScope itself. The view shouldn’t need to know what the valid sort orders are, and the controller definitely shouldn’t. Ruby’s Data.define is perfect for this:

class PageScope
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :fuel_type, :string, default: ""
  attribute :transmission, :string, default: ""
  attribute :budget, :string, default: ""
  attribute :sort_by, :string, default: "price_asc"

  SelectOption = Data.define(:key, :title)

  def sort_options
    [
      SelectOption.new("price_asc", "Price (low to high)"),
      SelectOption.new("price_desc", "Price (high to low)"),
      SelectOption.new("newest", "Newest first"),
      SelectOption.new("mileage", "Lowest mileage"),
    ]
  end

  def filter(cars)
    cars = cars.by_fuel(fuel_type) if fuel_type.present?
    cars = cars.by_transmission(transmission) if transmission.present?
    cars = cars.where(price: price_range) if price_range.present?

    cars = case sort_by
    when "price_asc" then cars.reorder(price: :asc)
    when "price_desc" then cars.reorder(price: :desc)
    when "newest" then cars.reorder(created_at: :desc)
    when "mileage" then cars.reorder(mileage: :asc)
    else cars
    end

    cars
  end
end

And the view:

<%= form.select :sort_by,
      @page_scope.sort_options.map { |o| [o.title, o.key] },
      {} %>

The PageScope now owns the full contract: what filters exist, what their defaults are, what the valid options are, and how they translate into queries. The controller just builds it from params. The view just renders it. Each piece does one job.

Tabs as form buttons

Not every filter is a dropdown. Sometimes you want tabs. “New stock”, “Sold”, “Reserved”. The instinct is to reach for links with query params, or a Stimulus controller that toggles content. But if the tab is really just a filter value, it can be a form button.

<%= form_with(model: @page_scope, ext_url: cars_path, scope: :page_scope,
              method: :get, id: "page-scopes",
              data: { controller: "auto-submit",
                      action: "input->auto-submit#submitForm" }) do |form| %>

  <%= form.hidden_field :status, value: form.object.status %>

  <nav class="flex -mb-px border-b border-gray-200">
    <% %w[available sold reserved all].each do |tab| %>
      <%= form.button :status, value: tab, type: :submit,
            class: class_names(
              "cursor-pointer py-4 px-6 text-sm font-medium border-b-2",
              "border-blue-500 text-blue-600" => form.object.status == tab,
              "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" => form.object.status != tab,
            ) do %>
        <%= tab.capitalize %>
      <% end %>
    <% end %>
  </nav>

  <%# dropdowns for the other filters below %>
<% end %>

form.button :status, value: :sold submits the form with page_scope[status]=sold. The hidden field ensures the current value is preserved when the auto-submit fires from a different input (a dropdown change, for instance). class_names handles the active state styling.

The result is a tabbed interface where tabs and dropdowns are all part of the same form, all submit via GET, and all end up in the URL. No separate tab logic. No JavaScript state. The PageScope just sees another attribute.

Using options_from_collection_for_select

When your select options come from the PageScope’s Data.define objects, you can use options_from_collection_for_select rather than building arrays manually. It’s a small thing, but it reads better:

<%= form.select :sort_by,
      options_from_collection_for_select(
        @page_scope.sort_options, :key, :title, form.object.sort_by
      ) %>

The method takes the collection, the value method (:key), the text method (:title), and the currently selected value. It builds the <option> tags for you. Cleaner than mapping over the array yourself, and it handles the selected attribute correctly.

For filters backed by ActiveRecord (say, filtering cars by a Make model), the same helper works with real records:

<%= form.select :make_id,
      options_from_collection_for_select(
        Make.order(:name), :id, :name, form.object.make_id
      ),
      { include_blank: "All makes" } %>

You can even put the query on the PageScope if you want the view completely free of data logic:

def makes_for_select
  Make.order(:name)
end

When not to use it

This pattern works well when you have a handful of filters that affect a single query and the state belongs in the URL. It’s not a search engine. If you’re building something with full-text search, faceted navigation, or complex boolean logic across many fields, you probably want something more substantial. I haven’t hit that wall yet, but I can see where it would be.

It’s also not the right fit if you have a single simple toggle. If it’s just “show archived / hide archived”, a query param link is simpler than a whole PageScope class. Don’t over-engineer a light switch.

And if the filter state shouldn’t be in the URL (sensitive data, for instance), this approach makes it visible. That may be a problem depending on your context.

The pattern in summary

  1. Define a PageScope inner class in your controller with ActiveModel::Model and ActiveModel::Attributes.
  2. Declare each filter as a typed attribute with a default.
  3. Add a filter(scope) method that applies the filters to whatever scope the controller passes in.
  4. Put select options and sort definitions on the PageScope using Data.define, so the view never needs to know what the valid choices are.
  5. Build the PageScope from permitted params in the controller action.
  6. Use form_with with method: :get and scope: :page_scope in the view.
  7. Use form.button for tab-style filters and form.select for dropdowns, all within the same form.
  8. Attach an auto-submit Stimulus controller for instant filtering.

The whole thing leans into what Rails is already good at: model-backed forms, typed attributes, GET requests, and the convention of keeping business logic in objects rather than scattered through controllers.

I’ve used this across three projects now, each time with different filter shapes, and it’s held up well. The pattern scales because adding a filter is just adding an attribute. The controller stays clean because the logic lives in the PageScope. And the URL tells you exactly what the page is showing, which turns out to be more useful than you’d think.

There we have it. A plain Ruby class, a GET form, and five lines of Stimulus. No gems required.

Ship it.