← All posts

Formatting Table Data with Rails Decorators

August 20, 2025 rails

In the previous post we set up Tabulator with Stimulus controllers and a JSON endpoint that returns { data, last_page }. That endpoint used as_json to serialize model attributes directly. It works, but formatting logic creeps in fast — dates need human-friendly formats, currencies need symbols, statuses need labels. Before long the controller is full of presentation code.

A decorator object fixes this. It wraps a model, adds formatted attributes, and gives the controller a clean as_json to call. No gems required.

The Problem

Here’s the kind of controller code that starts piling up:

def index
    users = User.order(:name).page(params[:page]).per(25)

    render json: {
        data: users.map { |u|
            {
                name: u.name,
                email: u.email,
                role: u.role.titleize,
                created_at: u.created_at.strftime("%b %d, %Y"),
                status: u.active? ? "Active" : "Inactive",
                url: user_path(u),
                edit_url: edit_user_path(u)
            }
        },
        last_page: users.total_pages
    }
end

Every table endpoint repeats this pattern — map over records, format fields, generate URLs. The controller is doing presentation work it shouldn’t own.

The Decorator

Create a plain Ruby class that wraps a model and exposes the formatted attributes:

app/decorators/user_decorator.rb:

class UserDecorator

    include Rails.application.routes.url_helpers

    def initialize(user)
        @user = user
    end

    def as_json(_options = nil)
        {
            name: @user.name,
            email: @user.email,
            role: @user.role.titleize,
            created_at: @user.created_at.strftime("%b %d, %Y"),
            status: status_label,
            url: user_path(@user),
            edit_url: edit_user_path(@user)
        }
    end

    private

    def status_label
        @user.active? ? "Active" : "Inactive"
    end
end

The controller becomes:

def index
    users = User.order(:name).page(params[:page]).per(25)

    render json: {
        data: users.map { |u| UserDecorator.new(u).as_json },
        last_page: users.total_pages
    }
end

Three lines in the controller. All formatting lives in the decorator where it’s easy to find and test.

A Base Decorator

Once you have a few decorators, extract the shared parts:

app/decorators/base_decorator.rb:

class BaseDecorator

    include Rails.application.routes.url_helpers

    def initialize(record)
        @record = record
    end

    def as_json(_options = nil)
        raise NotImplementedError, "#{self.class} must implement #as_json"
    end

    private

    def format_date(date)
        date&.strftime("%b %d, %Y")
    end

    def format_datetime(datetime)
        datetime&.strftime("%b %d, %Y %l:%M %p")
    end

    def format_currency(amount)
        return "$0.00" if amount.nil?
        "$#{'%.2f' % amount}"
    end

    def boolean_label(value, true_label: "Yes", false_label: "No")
        value ? true_label : false_label
    end
end

Now UserDecorator inherits the helpers:

class UserDecorator < BaseDecorator

    def as_json(_options = nil)
        {
            name: @record.name,
            email: @record.email,
            role: @record.role.titleize,
            created_at: format_date(@record.created_at),
            status: boolean_label(@record.active?, true_label: "Active", false_label: "Inactive"),
            url: user_path(@record),
            edit_url: edit_user_path(@record)
        }
    end
end

Another Example

A decorator for an invoices table with currency formatting:

app/decorators/invoice_decorator.rb:

class InvoiceDecorator < BaseDecorator

    def as_json(_options = nil)
        {
            number: @record.number,
            client: @record.client.name,
            amount: format_currency(@record.total),
            issued_on: format_date(@record.issued_on),
            due_on: format_date(@record.due_on),
            status: status_badge,
            url: invoice_path(@record)
        }
    end

    private

    def status_badge
        @record.paid? ? "Paid" : (@record.overdue? ? "Overdue" : "Pending")
    end
end

The Tabulator column definitions pair with this naturally. The status field returns a plain string that a custom formatter on the JavaScript side can render as a colored badge:

<%= render partial: "components/tabulator_table", locals: {
  json_url: api_invoices_path(format: :json),
  columns: [
    { title: "Invoice", field: "number", formatter: "link" },
    { title: "Client", field: "client" },
    { title: "Amount", field: "amount", sorter: "number", hozAlign: "right" },
    { title: "Issued", field: "issued_on", sorter: "date" },
    { title: "Due", field: "due_on", sorter: "date" },
    { title: "Status", field: "status", formatter: "badge" }
  ]
} %>

Decorating Collections

To avoid .map { |r| SomeDecorator.new(r).as_json } everywhere, add a class method:

class BaseDecorator

    def self.decorate(collection)
        collection.map { |record| new(record).as_json }
    end

    # ... rest of the class
end

The controller simplifies further:

def index
    users = User.order(:name).page(params[:page]).per(25)

    render json: {
        data: UserDecorator.decorate(users),
        last_page: users.total_pages
    }
end

Testing Decorators

Because decorators are plain Ruby objects, testing is straightforward:

class UserDecoratorTest < ActiveSupport::TestCase

    test "formats created_at as short date" do
        user = users(:one)
        user.created_at = Time.zone.parse("2026-01-15 10:30:00")
        json = UserDecorator.new(user).as_json

        assert_equal "Jan 15, 2026", json[:created_at]
    end

    test "returns Active for active users" do
        user = users(:one)
        user.active = true
        json = UserDecorator.new(user).as_json

        assert_equal "Active", json[:status]
    end

    test "includes url and edit_url" do
        user = users(:one)
        json = UserDecorator.new(user).as_json

        assert_equal "/users/#{user.id}", json[:url]
        assert_equal "/users/#{user.id}/edit", json[:edit_url]
    end
end

No request context needed, no controller setup. Create a model, decorate it, assert the output hash.

Tips

  • Keep decorators in app/decorators/. Rails autoloads this directory. One file per model keeps things easy to find.
  • Don’t use method_missing. It’s tempting to delegate everything to the wrapped model, but explicit methods are easier to trace and test. If you need the full model API, use delegate :name, :email, to: :@record for the specific attributes you need.
  • Decorators are not serializers. Serializers (like active_model_serializers or jsonapi-serializer) handle API contracts, content negotiation, and relationship embedding. Decorators are simpler — they format data for a specific UI context. Use whichever fits your needs.
  • Reuse across contexts. The same decorator works for Tabulator JSON endpoints, CSV exports, and PDF generators. Anywhere you need formatted model data, decorate first.
  • One decorator per table, not per model. If two tables show users differently — one with full details, one summary — create UserDetailDecorator and UserSummaryDecorator rather than adding conditionals.
Let’s work together! Tell Me About Your Project