Extract Filter Options to a Presenter

This is part 1 of the Controller Refactoring Epic.

Controllers that build filter options inline become bloated and repetitive. When the same options appear in multiple actions, you're duplicating logic that belongs elsewhere.

The Problem

class Admin::JobListingsController < ApplicationController
  def index
    @statuses = JobListing.statuses.keys.map { |s| [s.humanize, s] }
    @locations = JobListing.active.pluck(:location).compact.uniq.sort.map { |l| [l, l] }
    @departments = Department.order(:name).pluck(:name, :id)
    @salary_range = JobListing.active.pluck(:salary_max).compact.minmax
    # ... actual index logic
  end

  def filter_modal
    @statuses = JobListing.statuses.keys.map { |s| [s.humanize, s] }
    @locations = JobListing.active.pluck(:location).compact.uniq.sort.map { |l| [l, l] }
    # Same logic, copy-pasted
  end
end

Every time the filter UI needs options, you're writing database queries inline. Change how locations are formatted? Find every place you plucked them.

The Solution

# app/presenters/job_listings_filter_presenter.rb
class JobListingsFilterPresenter
  def statuses
    @statuses ||= JobListing.statuses.keys.map { |s| [s.humanize, s] }
  end

  def locations
    @locations ||= JobListing.active
                             .pluck(:location)
                             .compact
                             .uniq
                             .sort
                             .map { |l| [l, l] }
  end

  def departments
    @departments ||= Department.order(:name).pluck(:name, :id)
  end

  def salary_range
    @salary_range ||= JobListing.active.pluck(:salary_max).compact.minmax
  end

  # Convenience for views that need everything
  def to_h
    {
      statuses:,
      locations:,
      departments:,
      salary_range:
    }
  end
end
# Controller becomes minimal
class Admin::JobListingsController < ApplicationController
  def index
    @filter_options = JobListingsFilterPresenter.new
    # ... actual index logic
  end

  def filter_modal
    @filter_options = JobListingsFilterPresenter.new
  end
end

Why This Works

Memoization is automatic. Each method caches its result. If a view calls @filter_options.locations twice, the query runs once.

Testing is straightforward. The presenter is a plain Ruby object:

RSpec.describe JobListingsFilterPresenter do
  describe "#locations" do
    it "returns unique sorted locations" do
      create(:job_listing, location: "Chicago")
      create(:job_listing, location: "Austin")
      create(:job_listing, location: "Chicago") # duplicate

      expect(subject.locations).to eq [["Austin", "Austin"], ["Chicago", "Chicago"]]
    end
  end
end

Changes happen in one place. Need to exclude draft listings from location options? Update JobListingsFilterPresenter#locations. Every action using filter options gets the fix.

When to Use This Pattern

  • Filter/search UIs with multiple option sources
  • Dropdowns populated from database queries
  • Any repeated view data assembly logic

The presenter isn't about complexity — it's about having one source of truth for filter options.

← Back to all articles