Implementing Flexible Data Tables using Hotwire and ViewComponents
When building an admin dashboard for a web application, you inevitably need index tables for every model: users, schools, orders, products…

Implementing Flexible Data Tables using Hotwire and ViewComponents
When building an admin dashboard for a web application, you inevitably need index tables for every model: users, schools, orders, products, and so on. Each table shares the same structure: a header row with column labels, a body with one row per record, some row actions, and pagination. Yet without a shared solution, you end up duplicating that HTML for every model, each with hardcoded columns and slightly different behavior.
Beyond code duplication, these UI tables tend to have usability issues: showing all fields means many columns and horizontal scrolling, loading all records upfront doesn’t scale with thousands of rows, and without sorting or filtering, finding a specific record becomes a chore.
In this post, I’ll walk you through how I implemented an efficient and reusable admin table system in Ruby on Rails, powered by Hotwire. This approach makes it easy to handle large datasets with infinite scrolling, while keeping the codebase clean, consistent, and and highly customizable for different data and interactions across your tables.
We’ll start by implementing a basic index view the traditional way, the way we’d normally do it, before introducing any reusable or optimized structure.
Start with the basics
In this article, we’ll use the User model as our example, taking advantage of an already set-up controller, views, and table. But this approach is fully reusable and adaptable, you can apply it to any other model in your application.
This is the base index view from which we’ll start iterating and building. It’s a simple file that displays a table with all user records and their details. We’ll also add some basic styling to keep the table readable and organized.
<%# app/views/users/index.html.erb %>
<p class="font-bold my-8 text-black text-4xl">Users</p>
<%= turbo_frame_tag 'users' do %>
<table>
<thead>
<tr>
<th class="text-left">Actions</th>
<th class="text-left">ID</th>
<th class="text-left">First name</th>
<th class="text-left">Last name</th>
<th class="text-left">Email</th>
<th class="text-left">Role</th>
<th class="text-left">Last login</th>
<th class="text-left">Created At</th>
<th class="text-left">Updated At</th>
</tr>
</thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td class="pr-24">
<%= link_to "Show", user_path(user.id), data: { turbo_frame: :_top } %>
</td>
<td class="pr-24"><%= user.id %></td>
<td class="pr-24"><%= user.first_name %></td>
<td class="pr-24"><%= user.last_name %></td>
<td class="pr-24"><%= user.email %></td>
<td class="pr-24"><%= user.role %></td>
<td class="pr-24"><%= user.last_login.strftime("%m/%d/%Y") %></td>
<td class="pr-24"><%= user.created_at.strftime("%m/%d/%Y") %></td>
<td class="pr-24"><%= user.updated_at.strftime("%m/%d/%Y") %></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
The first column of the table is the “Actions” column. For simplicity, the only action available initially will be Show. This column can easily be customized to include other actions, such as a trash icon for deleting, a pencil icon for editing, or any other action that fits your application’s needs.
To navigate between these action views, we will use Turbo Frames. This not only optimizes performance by avoiding full page reloads, but also provides a smoother and faster user experience, keeps the page state intact, and reduces unnecessary data transfer. Using Turbo Frames allows us to update only the relevant parts of the page, making interactions feel more responsive and modern.
Now, using that index file open your browser, you should see a fully functional index view displaying all users and their details. You can even navigate to a user’s show page seamlessly using Turbo Frames, smooth and fast! At this point, your page should look something like this:
Looking at the table, we can see that there’s still plenty of room for improvement beyond just styling. Each record contains many fields, the table has multiple columns, which requires horizontal scrolling to see all the information. Additionally, since there are many records, users also need to scroll vertically to navigate through them. This records aren’t sorted, which makes them harder to find. How could we improve this to make the table easier to navigate and more user-friendly? Additionally, this implementation is currently limited to users. Ideally, we’d like to make it reusable for other entities, making our code more flexible and scalable.
Now let’s go step by step to transform this basic implementation into something truly powerful. We’ll refactor it into a fully reusable, component-driven solution that’s clean, dynamic, and optimized for performance. By combining View Components, Turbo, and modern Rails patterns, we’ll end up with a flexible system that can handle large datasets, adapt to different kinds of content, and provide a smooth, interactive experience for users, all while keeping our codebase elegant and maintainable.
Benefits of This Approach
- Reusable Components with View Components — The table structure is encapsulated in a component, so we can easily reuse it across multiple models and contexts.
- Customizable Rendering — Each column can define its own rendering logic (e.g., links, badges, images), making the table flexible for different use cases without duplicating code.
- Turbo-powered Interactivity — By leveraging Turbo Frames and Turbo Streams, the table supports dynamic updates, infinite scroll, and smooth interactions without full page reloads.
- Latest Rails 8 & Hotwire Stack — This approach is built on top of Rails 8 with Turbo, aligning with the latest Rails ecosystem best practices.
- Seamless User Experience — Infinite scroll and live updates provide a modern, app-like feel that improves usability and engagement.
- Maintainable and Scalable — Because the logic is centralized and generic, adding new tables or changing the layout only requires minimal updates.
Implementation steps
1. Setting up the foundations
Here’s a list of requirements you’ll need to have configured before starting
- Turbo Rails — Included by default in Rails 8. Handles fast, modern interactions without full page reloads.
- Styling (CSS framework or custom styles) — You can use any styling approach you prefer. In this guide, we’ll use **Tailwind CSS, **for simplicity.
- ViewComponent Gem — For creating reusable, testable visual components (**ViewComponent**).
2. Making the Table Reusable
To create a more reusable approach for all index views, we’ll start by refactoring our current view. The goal is to extract the table into a view component that can be reused across different models.
We’ll create a ViewComponent for the table and call it from the index view. Everything that was previously specific to users, such as the columns, the collection, and the model path, will be now passed as parameters. This makes it easy to apply the same solution to any other index view in the future.
To simplify our approach, reduce the chances of errors, and keep the view file clean, we’ll define which columns we want to display in the User model. Setting this in the model keeps the view uncluttered and avoids embedding logic directly in the template. Each column in the index table will correspond to an element in the DASHBOARD_COLUMNS defined in the model, making the table configuration centralized, consistent, and easy to maintain.
# app/models/user.rb
class User < ApplicationRecord
DASHBOARD_COLUMNS = [
{ id: "actions", field: nil, label: "Actions" },
{ id: "id", field: :id, label: "ID" },
{ id: "first_name", field: :first_name, label: "First Name" },
{ id: "last_name", field: :last_name, label: "Last Name" },
{ id: "email", field: :email, label: "Email" },
{ id: "role", field: :role, label: "Role" },
{ id: "last_login", field: :last_login, label: "Last login" },
{ id: "created_at", field: :created_at, label: "Created At" },
{ id: "updated_at", field: :updated_at, label: "Updated At" },
].freeze
end
To generate the table view component we will run the following command:
bin/rails generate component Table
This will generate three files: table_component.rb, table_component.html.erb and a test file.
We are going to start by defining what parameters the table component will receive:
# app/components/table_component.rb
class TableComponent < ViewComponent::Base
attr_reader :collection, :columns, :path
def initialize(collection:, columns:, path:)
@collection = collection
@columns = columns
@path = path
end
end
In the table component’s view file, we’ll use the same table structure we had before, but instead of building everything manually, we’ll iterate over the columns and the collection, making the code cleaner and more reusable.
To make it even more modular, we’ll extract each row into its own TableRowComponent. Generate it the same way:
bin/rails generate component TableRow
This allows us to render rows dynamically and keep the main table component focused solely on layout and structure.
<%# app/components/table_component.html.erb %>
<table>
<thead>
<tr>
<% columns.each do |column| %>
<th class="text-left">
<%= column[:label] %>
</th>
<% end %>
</tr>
</thead>
<tbody>
<% collection.each do |object| %>
<%= render TableRowComponent.new(
object: object,
columns: columns,
path: path
) %>
<% end %>
</tbody>
</table>
Below is the Ruby class for the TableRowComponent followed by its HTML template.
# app/components/table_row_component.rb
class TableRowComponent < ViewComponent::Base
attr_reader :object, :columns, :path
def initialize(object:, columns:, path:)
@object = object
@columns = columns
@path = path
end
end
Now, in the index view, we’ll keep the title and render the Table component, passing all the user-specific parameters as discussed earlier. Notice how easily we pass the columns here, and then, inside the component, we simply access the field or the label, depending on whether we’re rendering the table body or the header.
<%# app/views/users/index.html.erb %>
<p class="font-bold my-8 text-black text-4xl">Users</p>
<%= turbo_frame_tag 'users' do %>
<%= render TableComponent.new(
collection: @users,
columns: User::DASHBOARD_COLUMNS,
path: ->(id) { user_path(id) }
) %>
<% end %>
There we have it! Visually, it’s basically the same table, but now we’re using a highly reusable approach. However, it still doesn’t feel fully personalized, dates aren’t formatted the way we had them, and what if a user has a school and we want to display the school name? These are the kinds of enhancements we’ll tackle in the next step.
3. Lets customize the table
For the date, we can simply add a method in the tableRow component’s class. While we’re at it, we’ll also centralize all cell rendering logic here, including support for Proc-based columns, which we’ll use shortly for custom rendering like links. This keeps the view as declarative as possible.
# app/components/table_row_component.rb
private
def table_value_for(object, column)
field = column[:field]
return field.call(object, self) if field.is_a?(Proc)
value = object.send(field)
case value
when Time, Date, DateTime
value.strftime("%m/%d/%Y")
else
value
end
end
So now where we had the following code in the table_row_component.html.erb :
<%= object.send(column[:field]) %>
we will change it to:
<%= table_value_for(object, column[:field]) %>
Now, in EdTech applications (like the one we’re working with here) users commonly belong to a school and are enrolled in multiple classrooms. This many-to-many relationship between users and classrooms is a typical pattern: one user can attend several classrooms, and each classroom has many users. Let’s create the tables and their corresponding models, and set up the relationships between them.
With these models and their associations set up, we want to add two new columns: one showing the school the user belongs to, and another displaying the number of classrooms the user has.
The approach for each column is slightly different: the classroom count comes from a method in the User model (classroom_count), while the school name is a compound field.
Let’s start with the classroom count column. The approach here is very simple: we add this element to the DASHBOARD_COLUMNS array in the User model, and it will directly use the value returned by the method classroom_count in the model.
{ id: "classroom_count", field: :classroom_count, label: "Classrooms" }
Then for rendering the school name, in order to make accessing the school’s name easier and keep the table code clean, we can use Ruby’s delegate method in the User model:
delegate :name, to: :school, prefix: true, allow_nil: true
This creates a school_name method on the user, which automatically returns user.school.name and nil if the user has no school.
N+1 query warning: Accessing school_name on every table row triggers a separate SQL query to fetch each user's school. Since our goal is a solution that handles thousands of records, make sure to eager load the association in your controller: User.includes(:school).
Now, to display the school name, we add this element in the DASHBOARD_COLUMNS array:
{ id: "school_name", field: :school_name, label: "School Name" }
Open your browser, reload the page and there you have it! Two new customized columns in your table, with values that are not directly retrieved from the user’s database attributes.
But what if we want the school name to be a link to the school’s show page? This is a pretty standard feature for an index table; instead of just displaying the name, we want users to be able to navigate directly to the school’s page, ideally using Turbo Frames. To do this, we’ll need a slightly different approach.
How do we adapt our solution to handle this type of behavior? In the corresponding element of the DASHBOARD_COLUMNS array, we define the specific behavior we want the elements of that column to have:
{
id: "school",
field: ->(user, view) { view.link_to user.school_name, user.school, data: { turbo_frame: :_top } },
label: "School"
},
Since table_value_for already handles Proc fields, the template stays clean with no additional conditionals needed in the view. The full table_row_component.html.erb now looks like this:
<%# app/components/table_row_component.html.erb %>
<tr>
<% columns.each do |column| %>
<td class="pr-24" label="<%= column[:label] %>: ">
<% if column[:id] == 'actions' %>
<%= link_to "Show", path.call(object.id), data: { turbo_frame: :_top } %>
<% else %>
<%= table_value_for(object, column) %>
<% end %>
</td>
<% end %>
</tr>
So now we can see that our table columns can hold any type of content, implemented in a highly reusable way for building tables — yay!
However, we still face some challenges:
- Performance when loading many records at once. In this case it only 300 users, but as the title says, we are looking for a solution suitable for thousands of records.
- Difficulty finding specific records
- Sorting: what if we want to sort by first name in some cases, last name in others?
- Too many columns: it can be hard to visualize the data. For example, what if we only want to see each user’s school and hide the other columns?
These are the issues we’ll tackle in the next step, improving usability and making the tables even more user-friendly.
메타데이터
- post_id
- d3a82dedffd0
- slug
- implementing-flexible-data-tables-using-hotwire-and-viewcomponents-d3a82dedffd0
- url
- https://medium.com/neocoast/implementing-flexible-data-tables-using-hotwire-and-viewcomponents-d3a82dedffd0
- canonical_url
- https://medium.com/neocoast/implementing-flexible-data-tables-using-hotwire-and-viewcomponents-d3a82dedffd0
- author_url
- https://medium.com/@candelaria.lopez
- status
- ok
- fetched_at
- 2026-06-25 12:15:08