Building a Complex and Flexible Table Component in Rails

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:

  1. Architecture: Modular components with clear responsibilities
  2. Configuration: Flexible lambda-based column rendering
  3. Performance: N+1 query prevention and efficient DOM manipulation
  4. User Experience: Accessibility, responsive design, and intuitive interactions
  5. 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.

Looking for a fractional Rails engineer or CTO?

I take on a limited number of part-time clients.

Get in touch