While building EstateSync, I created many tables of varying complexity and length, but they still shared a lot of common patterns. Maintaining a consistent UI became increasingly challenging as I was using Tailwind, and I noticed that view code was being duplicated. That’s when I decided to explore ViewComponent and Phlex. While Phlex seemed interesting and promising, for my particular use case I preferred the simplicity of combining .erb files with ViewComponents.
The Challenge: Beyond Basic Tables
Building tables in erb
is simple enough, and to create a table ViewComponent also seemed simple at first. However when you consider all possible use cases things can get tricky quickly.
- Dynamic column configuration with various data types (text, links, badges, dropdowns)
- Advanced filtering with search, selects, and hierarchical dependencies
- Bulk action management with checkbox selection and form handling
- Sorting capabilities with URL state preservation
- Responsive design that works on all screen sizes
- Performance optimization to avoid N+1 queries
- Accessibility compliance for screen readers and keyboard navigation
Let’s build a table component that handles all of these requirements elegantly.
Component Architecture Overview
Our flexible table system consists of several focused components working together:
app/components/tables/
├── base_table_component.rb # Core table rendering engine
├── table_filters_component.rb # Advanced filtering system
├── table_column_renderer.rb # Flexible column rendering
└── table_bulk_actions_component.rb # Checkbox and bulk action management
Each component has a specific responsibility, making the system modular and testable.
Core Design Principles
1. Configuration Over Convention
Rather than hardcoding column types, we use flexible configuration:
columns = [
{
label: "Name",
value: ->(record) { "#{record.first_name} #{record.last_name}" },
sortable: true
},
{
label: "Status",
value: ->(record) {
content_tag(:span, record.status.humanize,
class: "px-2 py-1 rounded-full text-xs #{status_class(record)}")
}
}
]
2. Lambda-Based Rendering
Using lambdas gives us ultimate flexibility while maintaining clean separation:
# Simple text column
{ label: "Email", value: ->(record) { record.email } }
# Complex rendering with helpers
{
label: "Actions",
value: ->(record) {
dropdown_menu do
link_to "View", record_path(record)
link_to "Edit", edit_record_path(record)
link_to "Delete", record_path(record), method: :delete,
confirm: "Are you sure?"
end
}
}
3. Performance-First Approach
The component system includes several performance optimizations:
- Eager loading detection to prevent N+1 queries
- Lazy column rendering for expensive operations
- Batch processing for bulk actions
- Minimal DOM manipulation using Stimulus efficiently
Building the Base Table Component
Let’s start with the foundation - a flexible table component that can handle any data structure.
Core Component Structure
# app/components/tables/base_table_component.rb
class Tables::BaseTableComponent < ViewComponent::Base
include Tables::TableColumnRenderer
def initialize(
columns:,
records:,
model_name: nil,
sortable_columns: [],
bulk_actions: [],
pagy: nil,
current_params: {}
)
@columns = columns
@records = records
@model_name = model_name || infer_model_name
@sortable_columns = sortable_columns
@bulk_actions = bulk_actions
@pagy = pagy
@current_params = current_params
end
private
attr_reader :columns, :records, :model_name, :sortable_columns,
:bulk_actions, :pagy, :current_params
def has_bulk_actions?
bulk_actions.any?
end
def checkbox_column
return unless has_bulk_actions?
{
type: :checkbox,
header_checkbox: true,
cell_checkbox: true
}
end
def all_columns
[checkbox_column, *columns].compact
end
def infer_model_name
records.model.name.underscore.pluralize if records.respond_to?(:model)
end
end
Table Template Structure
The component template provides a clean, accessible table structure:
<!-- app/components/tables/base_table_component.html.erb -->
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg"
data-controller="table-bulk-actions"
data-table-bulk-actions-model-name-value="<%= model_name %>">
<table class="min-w-full divide-y divide-gray-300">
<thead class="bg-gray-50">
<tr>
<% all_columns.each do |column| %>
<th scope="col" class="<%= header_classes_for(column) %>">
<%= render_header_content(column) %>
</th>
<% end %>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
<% records.each do |record| %>
<tr class="<%= row_classes_for(record) %>">
<% all_columns.each do |column| %>
<td class="<%= cell_classes_for(column) %>">
<%= render_cell_content(record, column) %>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<%= render_pagination if pagy %>
</div>
Advanced Column Rendering System
The heart of our flexible table is the column rendering module that supports multiple rendering strategies.
The TableColumnRenderer Module
# app/components/tables/table_column_renderer.rb
module Tables::TableColumnRenderer
def render_cell_content(record, column)
# Lambda-based rendering (most flexible)
if column[:value].respond_to?(:call)
result = column[:value].call(record)
return result.respond_to?(:html_safe) ? result : result.to_s.html_safe
end
# Predefined column types for common patterns
case column[:type]
when :checkbox
render_checkbox_cell(record)
when :badge
render_badge_cell(record, column)
when :status_indicator
render_status_indicator(record, column)
when :action_dropdown
render_action_dropdown(record, column)
when :count_with_tooltip
render_count_with_tooltip(record, column)
else
# Fallback to simple attribute access
record.send(column[:key]) if column[:key]
end
end
private
def render_badge_cell(record, column)
value = record.send(column[:key])
content_tag :span, value.humanize,
class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium #{badge_class(value)}"
end
def render_status_indicator(record, column)
condition = column[:condition].call(record)
config = condition ? column[:true_config] : column[:false_config]
content_tag :div, class: "flex items-center" do
concat content_tag(:div, "", class: "w-2 h-2 rounded-full mr-2 #{config[:dot_class]}")
concat content_tag(:span, config[:text], class: "text-sm")
end
end
def render_action_dropdown(record, column)
# Complex dropdown menu rendering
# ... implementation details
end
end
Smart Filtering System
The filtering component supports multiple filter types with hierarchical dependencies.
TableFiltersComponent Implementation
# app/components/tables/table_filters_component.rb
class Tables::TableFiltersComponent < ViewComponent::Base
def initialize(filters:, form_url:, model_name:, turbo_frame_id: nil)
@filters = filters
@form_url = form_url
@model_name = model_name
@turbo_frame_id = turbo_frame_id || "#{model_name}_table"
end
private
attr_reader :filters, :form_url, :model_name, :turbo_frame_id
def render_filter(filter, form)
case filter[:type]
when :search
render_search_filter(filter, form)
when :select
render_select_filter(filter, form)
when :hierarchical_select
render_hierarchical_select(filter, form)
when :date_range
render_date_range_filter(filter, form)
end
end
def render_search_filter(filter, form)
content_tag :div, class: "relative" do
concat search_icon
concat form.text_field(filter[:name],
placeholder: filter[:placeholder],
class: search_input_classes,
data: { action: "input->#{turbo_frame_id}#filter" })
end
end
def render_hierarchical_select(filter, form)
form.select filter[:name],
filter[:options],
{ selected: filter[:selected] },
{
class: select_classes,
data: {
action: "change->#{turbo_frame_id}##{filter[:action]}",
target: filter[:target]
},
disabled: filter[:disabled]
}
end
end
Filter Configuration Examples
Filters are configured declaratively in the controller:
def owner_filters
[
{
type: :search,
name: :search,
placeholder: "Search owners...",
value: params[:search]
},
{
type: :select,
name: :status,
options: [["All", ""], ["Active", "active"], ["Inactive", "inactive"]],
selected: params[:status]
},
{
type: :hierarchical_select,
name: :estate_id,
options: estate_options,
selected: params[:estate_id],
action: "estateChanged",
target: "estateId"
},
{
type: :hierarchical_select,
name: :block_id,
options: block_options_for_estate(params[:estate_id]),
selected: params[:block_id],
disabled: params[:estate_id].blank?,
action: "blockChanged",
target: "blockId"
}
]
end
Bulk Actions with Stimulus
Bulk actions require coordinated checkbox management and form handling.
Stimulus Controller for Bulk Actions
// app/javascript/controllers/table_bulk_actions_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["selectAll", "selectRow", "submitButton", "selectedCount"];
static values = { modelName: String };
connect() {
this.updateUI();
}
selectAllChanged() {
const isChecked = this.selectAllTarget.checked;
this.selectRowTargets.forEach((checkbox) => {
checkbox.checked = isChecked;
});
this.updateUI();
}
selectRowChanged() {
const totalRows = this.selectRowTargets.length;
const checkedRows = this.selectRowTargets.filter((cb) => cb.checked).length;
// Update "select all" checkbox state
this.selectAllTarget.checked = checkedRows === totalRows;
this.selectAllTarget.indeterminate =
checkedRows > 0 && checkedRows < totalRows;
this.updateUI();
}
updateUI() {
const checkedCount = this.selectRowTargets.filter(
(cb) => cb.checked,
).length;
const hasSelection = checkedCount > 0;
// Update submit button state
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.disabled = !hasSelection;
}
// Update selected count display
if (this.hasSelectedCountTarget) {
this.selectedCountTarget.textContent = checkedCount;
}
// Toggle bulk action bar visibility
this.element.classList.toggle("has-selection", hasSelection);
}
getSelectedIds() {
return this.selectRowTargets
.filter((cb) => cb.checked)
.map((cb) => cb.value);
}
}
Bulk Action Configuration
Bulk actions are configured alongside the table:
bulk_actions = [
{
name: "Grant Portal Access",
url: grant_access_owners_path,
method: :post,
confirm: "Grant portal access to selected owners?",
button_class: "bg-green-600 hover:bg-green-700",
icon: "user-plus"
},
{
name: "Send Invitation",
url: send_invitations_owners_path,
method: :post,
confirm: "Send invitations to selected owners?",
button_class: "bg-blue-600 hover:bg-blue-700",
icon: "mail"
}
]
Putting It All Together: Complete Example
Let’s see how all the pieces work together in a real controller and view.
Controller Setup
# app/controllers/owners_controller.rb
class OwnersController < ApplicationController
def index
@owners = Owner.includes(:user, :estate)
.filtered_by(filter_params)
.sorted_by(sort_params)
@pagy, @owners = pagy(@owners, items: 25)
end
private
# Column configuration with lambda-based rendering
def owner_columns
[
{
label: "Name",
value: ->(owner) { "#{owner.first_name} #{owner.last_name}" },
sortable: true,
sort_key: "first_name"
},
{
label: "Email",
value: ->(owner) {
mail_to(owner.email, owner.email, class: "text-blue-600 hover:underline")
}
},
{
label: "Portal Access",
type: :status_indicator,
condition: ->(owner) { owner.user.present? },
true_config: {
dot_class: "bg-green-500",
text: "Active"
},
false_config: {
dot_class: "bg-red-500",
text: "No Access"
}
},
{
label: "Properties",
type: :count_with_tooltip,
key: :properties_count,
tooltip_content: ->(owner) {
"Properties: #{owner.properties.map(&:name).join(', ')}"
}
},
{
label: "Actions",
value: ->(owner) {
render "owners/action_dropdown", owner: owner
}
}
]
end
helper_method :owner_columns
end
View Template
The view becomes incredibly clean and declarative:
<!-- app/views/owners/index.html.erb -->
<div class="px-4 sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-xl font-semibold text-gray-900">Property Owners</h1>
<p class="mt-2 text-sm text-gray-700">
Manage property owners and their portal access.
</p>
</div>
</div>
<!-- Filters -->
<%= render Tables::TableFiltersComponent.new(
filters: owner_filters,
form_url: owners_path,
model_name: "owners"
) %>
<!-- Bulk Actions Form -->
<%= form_with url: bulk_action_owners_path, method: :post,
class: "mt-6" do |form| %>
<!-- Bulk Action Buttons -->
<div class="mb-4 hidden" data-table-bulk-actions-target="bulkActionBar">
<button type="submit" name="action" value="grant_access"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md"
data-table-bulk-actions-target="submitButton"
data-confirm="Grant portal access to selected owners?">
Grant Portal Access
</button>
</div>
<!-- Table Component -->
<%= render Tables::BaseTableComponent.new(
columns: owner_columns,
records: @owners,
model_name: "owners",
pagy: @pagy,
bulk_actions: bulk_actions,
sortable_columns: %w[first_name email created_at],
current_params: params.except(:controller, :action)
) %>
<% end %>
</div>
Advanced Column Types
The system supports complex column types for common patterns:
# Custom badge column with conditional styling
{
label: "Status",
value: ->(property) {
status = property.occupancy_status
badge_class = case status
when 'occupied' then 'bg-green-100 text-green-800'
when 'vacant' then 'bg-yellow-100 text-yellow-800'
when 'maintenance' then 'bg-red-100 text-red-800'
end
content_tag(:span, status.humanize,
class: "inline-flex px-2 py-1 text-xs font-semibold rounded-full #{badge_class}")
}
},
# Action dropdown with context-aware options
{
label: "Actions",
value: ->(property) {
dropdown_menu("Property Actions") do
link_to "View Details", property_path(property), class: "block px-4 py-2 text-sm"
link_to "Edit Property", edit_property_path(property), class: "block px-4 py-2 text-sm"
if property.vacant?
link_to "Mark Occupied", mark_occupied_property_path(property),
method: :patch, class: "block px-4 py-2 text-sm text-green-600"
end
divider
link_to "Delete", property_path(property),
method: :delete,
class: "block px-4 py-2 text-sm text-red-600",
confirm: "Are you sure you want to delete #{property.name}?"
end
}
}
Performance Optimizations
Preventing N+1 Queries
The table component includes several performance safeguards:
# Controller includes for common associations
def index
@owners = Owner.includes(:user, :estate, :properties)
.filtered_by(filter_params)
# Component automatically detects eager loading
end
# Column configuration with performance awareness
{
label: "Properties",
value: ->(owner) {
# Safe because properties are already loaded
"#{owner.properties.size} properties"
}
}
Lazy Loading for Expensive Operations
# Expensive column rendering can be deferred
{
label: "Financial Summary",
value: ->(owner) {
# Only calculate when needed
Rails.cache.fetch("owner_#{owner.id}_financial_summary", expires_in: 1.hour) do
calculate_financial_summary(owner)
end
}
}
Efficient Stimulus Controllers
// Optimized checkbox handling
selectRowChanged() {
// Batch DOM updates to prevent layout thrashing
requestAnimationFrame(() => {
this.updateUI()
})
}
Testing Strategy
A robust table component needs comprehensive testing at multiple levels.
Component Testing
# test/components/tables/base_table_component_test.rb
require "test_helper"
class Tables::BaseTableComponentTest < ViewComponent::TestCase
def setup
@records = [
OpenStruct.new(id: 1, name: "John Doe", email: "john@example.com"),
OpenStruct.new(id: 2, name: "Jane Smith", email: "jane@example.com")
]
@columns = [
{ label: "Name", value: ->(record) { record.name } },
{ label: "Email", value: ->(record) { record.email } }
]
end
def test_renders_table_headers
render_inline(Tables::BaseTableComponent.new(
columns: @columns,
records: @records
))
assert_selector "th", text: "Name"
assert_selector "th", text: "Email"
end
def test_renders_table_rows
render_inline(Tables::BaseTableComponent.new(
columns: @columns,
records: @records
))
assert_selector "td", text: "John Doe"
assert_selector "td", text: "john@example.com"
end
def test_bulk_actions_add_checkbox_column
render_inline(Tables::BaseTableComponent.new(
columns: @columns,
records: @records,
bulk_actions: [{ name: "Delete", url: "/delete" }]
))
assert_selector "input[type='checkbox'][data-table-bulk-actions-target='selectAll']"
assert_selector "input[type='checkbox'][data-table-bulk-actions-target='selectRow']", count: 2
end
end
System Testing
# test/system/owners_index_test.rb
require "application_system_test_case"
class OwnersIndexTest < ApplicationSystemTestCase
def test_filtering_owners
visit owners_path
fill_in "Search owners...", with: "John"
# Wait for Turbo frame update
assert_selector "tbody tr", count: 1
assert_text "John Doe"
end
def test_bulk_actions
visit owners_path
# Select multiple owners
check "owner_#{owners(:john).id}"
check "owner_#{owners(:jane).id}"
# Bulk action button should be enabled
assert_selector "button[type='submit']:not([disabled])", text: "Grant Portal Access"
click_button "Grant Portal Access"
accept_confirm
assert_text "Portal access granted to 2 owners"
end
end
Real-World Benefits
After implementing this table component in EstateSync:
Development Velocity
- New index views: 5 minutes vs. 2+ hours previously
- Styling changes: Single location vs. 8+ files
- Bug fixes: Centralized vs. scattered across views
Consistency & Maintenance
- Visual consistency: All tables follow the same design system
- Behavioral consistency: Sorting, filtering, and bulk actions work identically
- Easy updates: New features automatically available to all tables
Feature Richness
- ✅ Advanced filtering with hierarchical dependencies
- ✅ Intelligent sorting with URL state management
- ✅ Accessible bulk actions with keyboard navigation
- ✅ Responsive design that works on all screen sizes
- ✅ Performance optimizations built-in
Conclusion
Building a complex and flexible table component requires careful consideration of:
- Architecture: Modular components with clear responsibilities
- Configuration: Flexible lambda-based column rendering
- Performance: N+1 query prevention and efficient DOM manipulation
- User Experience: Accessibility, responsive design, and intuitive interactions
- Developer Experience: Clear APIs and comprehensive testing
The result is a system that scales from simple data display to complex multi-action interfaces while maintaining consistency and performance.
Built with Rails 8, ViewComponent, Stimulus, and Tailwind CSS. The patterns shown here are adaptable to other Rails configurations and frontend frameworks.