Software Engineering Ruby on Rails Web Development View Component / August 8, 2025 / 10 mins read / By Wagner Matos

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.