Integrating Tabulator with Stimulus
You need interactive tables — sorting, pagination, filtering — but you don’t want to pull in a full SPA framework. Tabulator is a standalone JavaScript table library that handles all of that, and it pairs well with Stimulus. This post shows how to wrap Tabulator in a controller hierarchy so you can drop data tables into any Rails view with a partial and a few lines of configuration.
This follows the same pattern I described in Rails Partials + Stimulus Controllers as Reusable Components — a partial for markup, a controller for behavior, and a naming convention to tie them together.
Install Tabulator
Pin it via importmap:
bin/importmap pin tabulator-tables
Or with Yarn:
yarn add tabulator-tables
Add the CSS in your layout head:
<link rel="stylesheet" href="https://unpkg.com/tabulator-tables/dist/css/tabulator_bootstrap5.min.css">
Tabulator ships several theme stylesheets. tabulator_bootstrap5.min.css works well if you’re already using Bootstrap, but there are options for Bulma, Materialize, and a default theme.
The Base Controller
The idea is a base Stimulus controller that handles Tabulator initialization and teardown, with subclasses that provide specific configuration. Start with BaseTabulatorController:
app/javascript/controllers/base_tabulator_controller.js:
import { Controller } from "@hotwired/stimulus"
import { TabulatorFull as Tabulator } from "tabulator-tables"
import merge from "lodash.merge"
import "./tabulator_formatters"
export default class extends Controller {
static targets = ["table"]
get defaultConfig() {
return {
layout: "fitColumns",
pagination: true,
paginationSize: 25,
placeholder: "No records found",
}
}
get tableConfig() {
return {}
}
connect() {
const config = merge({}, this.defaultConfig, this.tableConfig)
this.table = new Tabulator(this.tableTarget, config)
}
disconnect() {
if (this.table) {
this.table.destroy()
this.table = null
}
}
}
defaultConfig returns sensible defaults — layout, pagination size, a placeholder message. Subclasses override tableConfig with their own settings. lodash.merge does a deep merge so subclasses can override nested properties without wiping out the rest.
Install lodash.merge the same way you installed Tabulator:
bin/importmap pin lodash.merge
The disconnect() method is important. Tabulator creates DOM elements outside the controller’s element — without destroy(), navigating with Turbo will leave orphaned elements behind. Same pattern as the Flatpickr post.
Custom Formatters
Tabulator’s built-in formatters cover the basics, but you’ll likely need project-specific ones. Register them via Tabulator.extendModule in a separate file that the base controller imports:
app/javascript/controllers/tabulator_formatters.js:
import { TabulatorFull as Tabulator } from "tabulator-tables"
Tabulator.extendModule("format", "formatters", {
link: function (cell) {
const value = cell.getValue()
const url = cell.getRow().getData().url
if (!url) return value
return `<a href="${url}">${value}</a>`
},
linkButton: function (cell, params) {
const url = cell.getValue()
const label = params.label || "View"
const css = params.css || "btn btn-sm btn-outline-primary"
if (!url) return ""
return `<a href="${url}" class="${css}">${label}</a>`
},
badge: function (cell, params) {
const value = cell.getValue()
if (!value) return ""
const css = params.css || "badge bg-secondary"
return `<span class="${css}">${value}</span>`
},
})
Because the base controller imports this file, the formatters are registered before any table initializes. Use them in column definitions like any built-in formatter:
{ title: "Name", field: "name", formatter: "link" },
{ title: "Status", field: "status", formatter: "badge", formatterParams: { css: "badge bg-success" } },
{ title: "", field: "edit_url", formatter: "linkButton", formatterParams: { label: "Edit" } },
Static HTML Tables
Sometimes you already have a <table> rendered server-side and just want to add sorting and filtering on top. TabulatorHtmlController extends the base controller to handle this:
app/javascript/controllers/tabulator_html_controller.js:
import BaseTabulatorController from "./base_tabulator_controller"
export default class extends BaseTabulatorController {
get tableConfig() {
return {
pagination: false,
headerSort: true,
}
}
connect() {
// Tabulator reads directly from the existing <table> element
super.connect()
}
}
Wrap any <table> to make it sortable:
<div data-controller="tabulator-html">
<table data-tabulator-html-target="table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= user.name %></td>
<td><%= user.email %></td>
<td><%= user.created_at.strftime("%Y-%m-%d") %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
That’s it. No JSON endpoint, no column definitions. Tabulator reads the existing HTML and adds sorting to each column automatically.
Remote Data Tables
For larger datasets you want server-side pagination. TabulatorController extends the base with remote data fetching:
app/javascript/controllers/tabulator_controller.js:
import BaseTabulatorController from "./base_tabulator_controller"
export default class extends BaseTabulatorController {
static values = {
url: String,
columns: { type: Array, default: [] },
}
get tableConfig() {
return {
columns: this.columnsValue,
ajaxURL: this.urlValue,
paginationMode: "remote",
sortMode: "remote",
filterMode: "remote",
paginationSize: 25,
ajaxResponse: function (_url, _params, response) {
return {
data: response.data,
last_page: response.last_page,
}
},
}
}
}
Tabulator sends page number, sort, and filter parameters in the request. Your Rails controller returns JSON in the shape Tabulator expects:
app/controllers/api/users_controller.rb:
class Api::UsersController < ApplicationController
def index
page = params[:page]&.to_i || 1
per_page = params[:size]&.to_i || 25
users = User.order(sort_column => sort_direction)
.page(page)
.per(per_page)
render json: {
data: users.as_json(only: [:id, :name, :email, :created_at]),
last_page: users.total_pages
}
end
private
def sort_column
%w[name email created_at].include?(params[:sort]) ? params[:sort] : "name"
end
def sort_direction
%w[asc desc].include?(params[:direction]) ? params[:direction] : "asc"
end
end
Whitelist sort columns to prevent arbitrary column access. Tabulator sends sort, direction, page, and size parameters by default.
The Reusable Partial
With the remote controller in place, you can create a partial that makes adding tables a one-liner:
app/views/components/_tabulator_table.html.erb:
<div data-controller="tabulator"
data-tabulator-url-value="<%= json_url %>"
data-tabulator-columns-value="<%= columns.to_json %>">
<div data-tabulator-target="table"></div>
</div>
Now any view just renders the partial with a URL and column definitions:
<%= render partial: "components/tabulator_table", locals: {
json_url: api_users_path(format: :json),
columns: [
{ title: "Name", field: "name", formatter: "link" },
{ title: "Email", field: "email" },
{ title: "Created", field: "created_at", sorter: "date" },
{ title: "", field: "edit_url", formatter: "linkButton", formatterParams: { label: "Edit" } }
]
} %>
The column definitions are passed as a Ruby array of hashes, converted to JSON in the partial, and picked up by the Stimulus controller as a value. Adding a new table to another view is just another render call with different columns and a different URL.
Tips
- Always destroy in
disconnect(). Tabulator creates elements outside the controller’s root element. Without cleanup, Turbo page transitions will accumulate orphaned DOM nodes. - Use
lodash.mergefor config. A shallow merge would wipe out nested objects likeajaxRequestFuncoptions. Deep merging lets subclasses override specific keys without losing the rest. - Turbo compatibility. The
connect()/disconnect()lifecycle handles Turbo navigation automatically — the table initializes when the element enters the DOM and tears down when it leaves. No special Turbo event listeners needed. - Keep formatters in one file. Registering all custom formatters in a single import keeps them discoverable and ensures they’re loaded before any table renders.
- Column widths.
layout: "fitColumns"distributes width evenly. For tables with many columns, switch tolayout: "fitDataStretch"so columns size to their content and the last column fills remaining space.