---
title: High-Quality Inline-Editable Table
slug: inline-editable-table
category: Tables
summary: A spreadsheet-grade data table — click-to-edit cells, Tab/Enter navigation, debounced autosave with a saving/saved indicator, sortable columns, and debounced server-side search.
tags: [stimulus, hotwire, autosave, sorting, search, tables, turbo]
status: stable
visibility: public
source_project: rsb.llamapress.ai
layers: [view, stimulus_js, controller, model, sql]
---

# High-Quality Inline-Editable Table

> ⚠️ **Cookbook example — not live code.** Every code block below is an **example
> snippet**, **not part of the llamapress.ai codebase**, and **not running on this
> server**. This is a reference recipe for a **Leo instance (an AI coding agent) to
> implement in its own app** — read it to understand the pattern, then recreate it there.

A data table that feels like a spreadsheet: every cell is click-to-edit, you can
`Tab`/`Shift+Tab`/`Enter` between cells, edits **autosave** after you stop typing
(with a live "Saving…/Saved" indicator), every column is **sortable**, and there's
a fast **debounced search**. No full-page reloads for edits; sorting and search use
clean URL params so the view is shareable and back-button friendly.

> **When to use:** admin data grids, CRM record lists, any "edit a list of rows in
> place" screen. **When not to:** if a row needs a rich multi-field form, use a
> drawer/modal instead — inline editing shines for flat, scalar columns.

> The example below uses a `Contact` model with `name/email/company/status` columns.
> Swap in your own model and columns — see **How to adapt** at the bottom.

---

## The 80/20 in one breath

1. A normal Rails `index` that renders a `<table>` of records.
2. Each editable `<td>` carries `contenteditable` + data attributes: the record id,
   the column name, and the original value.
3. One Stimulus controller (`inline-table`) handles focus/Tab navigation, a per-cell
   debounce, the autosave `fetch`, and the indicator.
4. One JSON action (`PATCH /contacts/:id`) updates a single attribute and returns
   `{ ok: true }`.
5. Sorting + search are plain GET links/inputs that set `?sort=&dir=&q=` — the server
   does the work, wrapped in a Turbo Frame so only the table swaps. No client state.

Everything else below is just making those five things robust.

---

## Layer 1 — Model & SQL

`app/models/contact.rb` — the whitelists are the security boundary; never let a
client-supplied column name reach `update`/`order` without passing through one.

```ruby
class Contact < ApplicationRecord
  belongs_to :account

  EDITABLE_COLUMNS = %w[name email phone company status].freeze
  SORTABLE_COLUMNS = (EDITABLE_COLUMNS + %w[created_at updated_at]).freeze

  scope :search, ->(q) {
    return all if q.blank?
    term = "%#{sanitize_sql_like(q.strip)}%"
    where("name ILIKE :t OR email ILIKE :t OR company ILIKE :t", t: term)
  }
end
```

Optional indexes once the table is large (`db/migrate/XXXX_add_indexes_to_contacts.rb`):

```ruby
class AddIndexesToContacts < ActiveRecord::Migration[7.1]
  def change
    add_index :contacts, :name
    add_index :contacts, :updated_at

    # ILIKE '%term%' can't use a btree index (leading wildcard). A pg_trgm GIN
    # index keeps search sub-100ms past ~50k rows. Drop this for small tables.
    enable_extension "pg_trgm" unless extension_enabled?("pg_trgm")
    add_index :contacts, :name, using: :gin, opclass: :gin_trgm_ops, name: "idx_contacts_name_trgm"
  end
end
```

---

## Layer 2 — Controller

`app/controllers/contacts_controller.rb` — `index` (sort + search + paginate) and
`update` (single-cell autosave).

```ruby
class ContactsController < ApplicationController
  before_action :authenticate_user!

  def index
    sort = Contact::SORTABLE_COLUMNS.include?(params[:sort]) ? params[:sort] : "created_at"
    dir  = params[:dir] == "asc" ? "asc" : "desc"

    @contacts = current_user.account.contacts
                            .search(params[:q])
                            .order(Arel.sql("#{sort} #{dir}"))
                            .page(params[:page]).per(50)

    @sort = sort
    @dir  = dir
  end

  # PATCH /contacts/:id  — body: { column: "email", value: "new@x.com" }
  def update
    contact = current_user.account.contacts.find(params[:id]) # tenant-scoped
    column  = params[:column].to_s

    unless Contact::EDITABLE_COLUMNS.include?(column)
      return render json: { ok: false, error: "Column not editable" }, status: :unprocessable_entity
    end

    if contact.update(column => params[:value])
      render json: { ok: true, value: contact.public_send(column), updated_at: contact.updated_at.iso8601 }
    else
      render json: { ok: false, errors: contact.errors.full_messages }, status: :unprocessable_entity
    end
  end
end
```

Route — add to `config/routes.rb`:

```ruby
resources :contacts, only: [:index, :update]
```

Header-sort helper — `app/helpers/contacts_helper.rb`:

```ruby
module ContactsHelper
  # Toggle sort direction while preserving other query params (search, page).
  def sort_params(col)
    dir = (params[:sort] == col && params[:dir] == "asc") ? "desc" : "asc"
    contacts_path(request.query_parameters.merge(sort: col, dir: dir))
  end
end
```

---

## Layer 3 — The View

`app/views/contacts/index.html.erb` — the magic is the `data-*` attributes on each
cell and the sortable headers, all inside a `turbo_frame_tag`.

```erb
<div data-controller="inline-table" data-inline-table-url-value="/contacts">

  <div class="flex items-center justify-between mb-3">
    <%= form_with url: contacts_path, method: :get, data: { inline_table_target: "searchForm" } do |f| %>
      <%= f.search_field :q, value: params[:q], placeholder: "Search…",
            data: { action: "input->inline-table#search" },
            class: "border rounded px-3 py-1.5 w-72" %>
      <%= f.hidden_field :sort, value: @sort %>
      <%= f.hidden_field :dir,  value: @dir %>
    <% end %>
    <span data-inline-table-target="status" class="text-xs text-gray-400"></span>
  </div>

  <%= turbo_frame_tag "contacts-table" do %>
    <table class="min-w-full text-sm">
      <thead>
        <tr>
          <% { "name" => "Name", "email" => "Email", "company" => "Company", "status" => "Status" }.each do |col, label| %>
            <th class="text-left px-3 py-2 select-none">
              <%= link_to sort_params(col), data: { turbo_frame: "contacts-table" }, class: "inline-flex items-center gap-1 hover:text-blue-600" do %>
                <%= label %>
                <% if @sort == col %><span><%= @dir == "asc" ? "▲" : "▼" %></span><% end %>
              <% end %>
            </th>
          <% end %>
        </tr>
      </thead>
      <tbody>
        <% @contacts.each do |c| %>
          <tr data-id="<%= c.id %>">
            <% %w[name email company status].each do |col| %>
              <td
                contenteditable="true"
                tabindex="0"
                data-inline-table-target="cell"
                data-id="<%= c.id %>"
                data-column="<%= col %>"
                data-original="<%= c.public_send(col) %>"
                data-action="focus->inline-table#cellFocus blur->inline-table#cellBlur input->inline-table#cellInput keydown->inline-table#cellKeydown"
                class="px-3 py-2 border-t outline-none focus:bg-blue-50 focus:ring-2 focus:ring-inset focus:ring-blue-400"
              ><%= c.public_send(col) %></td>
            <% end %>
          </tr>
        <% end %>
      </tbody>
    </table>
    <div class="mt-4"><%= paginate @contacts if respond_to?(:paginate) %></div>
  <% end %>
</div>
```

> **Why `turbo_frame_tag`:** sort-header clicks and search submits swap **only the
> table**, preserving scroll and the rest of the page — no custom JS for sorting. The
> Stimulus controller only owns editing.

---

## Layer 4 — The Stimulus controller (the heart of it)

`app/javascript/controllers/inline_table_controller.js` — copy this verbatim; it's
generic and needs no per-model edits.

```javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["cell", "status", "searchForm"]
  static values  = { url: String, debounce: { type: Number, default: 600 } }

  connect() {
    this._timers = new Map()   // cell -> debounce timer
    this._dirty  = new Set()   // cells with unsaved edits
    this._csrf   = document.querySelector('meta[name="csrf-token"]')?.content
    window.addEventListener("beforeunload", this._warnIfDirty)
  }

  disconnect() {
    this._timers.forEach(clearTimeout)
    window.removeEventListener("beforeunload", this._warnIfDirty)
  }

  // --- Editing ---
  cellFocus(e) {
    const range = document.createRange()   // select-all on focus → typing replaces
    range.selectNodeContents(e.target)
    const sel = window.getSelection()
    sel.removeAllRanges(); sel.addRange(range)
  }

  cellInput(e) {
    const cell = e.target
    this._dirty.add(cell)
    clearTimeout(this._timers.get(cell))
    this._timers.set(cell, setTimeout(() => this.save(cell), this.debounceValue))
    this.setStatus("editing")
  }

  cellBlur(e) {                            // flush pending save on blur
    const cell = e.target
    if (this._dirty.has(cell)) { clearTimeout(this._timers.get(cell)); this.save(cell) }
  }

  cellKeydown(e) {
    const cell = e.target
    if (e.key === "Enter") {               // commit + move down
      e.preventDefault(); cell.blur(); this.moveBy(cell, +1, "row")
    } else if (e.key === "Escape") {       // revert
      e.preventDefault()
      cell.textContent = cell.dataset.original
      this._dirty.delete(cell)
      clearTimeout(this._timers.get(cell))
      cell.blur(); this.setStatus("idle")
    } else if (e.key === "Tab") {          // next / prev cell
      e.preventDefault(); cell.blur(); this.moveBy(cell, e.shiftKey ? -1 : +1, "cell")
    }
  }

  // --- Navigation ---
  moveBy(cell, delta, unit) {
    const cells = this.cellTargets
    const idx = cells.indexOf(cell)
    const next = unit === "cell"
      ? cells[idx + delta]
      : cells[idx + delta * this._columnsPerRow()]
    if (next) next.focus()
  }

  _columnsPerRow() {
    const firstId = this.cellTargets[0]?.dataset.id
    return this.cellTargets.filter(c => c.dataset.id === firstId).length
  }

  // --- Saving ---
  async save(cell) {
    const value = cell.textContent.trim()
    if (value === cell.dataset.original) { this._dirty.delete(cell); this.setStatus("idle"); return }

    this.setStatus("saving")
    cell.classList.add("opacity-60")
    try {
      const res = await fetch(`${this.urlValue}/${cell.dataset.id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json", "Accept": "application/json", "X-CSRF-Token": this._csrf },
        body: JSON.stringify({ column: cell.dataset.column, value })
      })
      const data = await res.json()
      if (!res.ok || !data.ok) throw new Error((data.errors || ["Save failed"]).join(", "))

      cell.dataset.original = data.value ?? value
      cell.textContent = data.value ?? value
      this._dirty.delete(cell)
      this.flash(cell, "ok"); this.setStatus("saved")
    } catch (err) {
      cell.textContent = cell.dataset.original   // revert on failure
      this._dirty.delete(cell)
      this.flash(cell, "err"); this.setStatus("error", err.message)
    } finally {
      cell.classList.remove("opacity-60")
    }
  }

  // --- Search ---
  search() {
    clearTimeout(this._searchTimer)
    this._searchTimer = setTimeout(() => this.searchFormTarget.requestSubmit(), 300)
  }

  // --- Indicator ---
  setStatus(state, msg) {
    if (!this.hasStatusTarget) return
    const map = { idle: "", editing: "✎ Editing…", saving: "⟳ Saving…", saved: "✓ Saved", error: `⚠ ${msg || "Save failed"}` }
    this.statusTarget.textContent = map[state] || ""
    this.statusTarget.className = "text-xs " + (state === "error" ? "text-red-500" : state === "saved" ? "text-green-600" : "text-gray-400")
    if (state === "saved") {
      clearTimeout(this._savedTimer)
      this._savedTimer = setTimeout(() => { if (!this._dirty.size) this.setStatus("idle") }, 1500)
    }
  }

  flash(cell, kind) {
    const cls = kind === "ok" ? "bg-green-100" : "bg-red-100"
    cell.classList.add(cls)
    setTimeout(() => cell.classList.remove(cls), 700)
  }

  _warnIfDirty = (e) => { if (this._dirty?.size) { e.preventDefault(); e.returnValue = "" } }
}
```

---

## Gotchas (the hard-won stuff)

- **`contenteditable` pastes rich HTML.** A paste from Word/Excel injects styled
  `<span>`s. Reading `cell.textContent` (not `innerHTML`) on save neutralizes it. For
  a cleaner caret, also strip on paste:
  `e.preventDefault(); document.execCommand("insertText", false, e.clipboardData.getData("text/plain"))`.
- **Debounce *and* flush on blur.** Debounce-only loses the save when a user edits then
  immediately clicks away. `cellBlur` flushes.
- **Tab default must be prevented.** Native `Tab` moves focus by DOM order including
  links/buttons; `e.preventDefault()` + explicit `moveBy` keeps it inside the grid.
- **Revert on server rejection.** Validation failures (bad email, etc.) must restore
  `data-original` so the cell never shows an unsaved-but-rejected value.
- **Mass-assignment safety is the whitelist.** Because the column name is dynamic,
  `EDITABLE_COLUMNS.include?(column)` — not `params.permit` — is the boundary. Without
  it a caller could PATCH `account_id` or `admin`.
- **Tenant-scope the lookup.** Start from `current_user.account.contacts` so
  `find(params[:id])` can't reach another tenant's row.
- **Let the server own sorting/search.** Re-render via the Turbo Frame so new cells get
  fresh `data-original` from the server — no stale client state to reconcile.
- **`ILIKE '%term%'` can't use a btree index** (leading wildcard). The `pg_trgm` GIN
  index keeps search fast past ~50k rows. Skip for small tables.
- **Select-all on focus** (`cellFocus`) is what makes it feel like a spreadsheet.

---

## Files this pattern touches

```
app/models/<model>.rb                                  # EDITABLE/SORTABLE_COLUMNS + search scope
app/controllers/<plural>_controller.rb                 # index (sort/search) + update (autosave)
app/helpers/<plural>_helper.rb                         # sort_params
app/views/<plural>/index.html.erb                      # table + turbo_frame + search
app/javascript/controllers/inline_table_controller.js  # the Stimulus controller (copy verbatim)
config/routes.rb                                        # resources only: [:index, :update]
db/migrate/XXXX_add_indexes.rb                          # optional, for scale
```

## How to adapt to your schema

1. Replace `Contact`/`contacts` with your model, and the column lists in
   `EDITABLE_COLUMNS` and the view's `%w[...]` loops.
2. Keep the **whitelist** guard — it's the security boundary.
3. Replace `current_user.account.contacts` with your tenant scope.
4. Drop the `pg_trgm` migration if your table is small; drop `paginate` if you don't
   use Kaminari.
5. The `inline_table_controller.js` is generic — copy it verbatim, no edits needed.
6. For a `select`-type column (e.g. `status`), swap the `contenteditable` `<td>` for a
   `<select data-action="change->inline-table#cellSelect">` and add a `cellSelect`
   handler that calls `save(e.target.closest('td'))` — see the
   [multi-select patterns](/cookbook/multi-select-patterns) guide.
