Formatting Table Data with Rails Decorators
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, usedelegate :name, :email, to: :@recordfor the specific attributes you need. - Decorators are not serializers. Serializers (like
active_model_serializersorjsonapi-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
UserDetailDecoratorandUserSummaryDecoratorrather than adding conditionals.