Skip to content

Server-side data with Ruby on Rails

This tutorial shows how to wire Handsontable’s dataProvider plugin to a Ruby on Rails API-only backend. The backend handles pagination, sorting, and filtering on the server. The frontend displays results and sends every edit back to the API.

View full example on GitHub

Difficulty: Intermediate
Time: ~30 minutes
Backend: Ruby 3.2+, Rails 7.1+, kaminari for pagination, rack-cors for CORS

What you’ll build

An Order Management grid that:

  • Loads orders page by page from a Rails API
  • Sorts orders by any column on the server
  • Filters orders by column value on the server
  • Creates, updates, and deletes rows via dedicated collection endpoints
  • Handles Rails’ default snake_case payloads without mismatching Handsontable column keys

Before you begin

  • Docker and Docker Compose installed
  • Node.js 18 or later and npm installed

No local Ruby or Rails installation is required — the backend runs inside Docker.

Step 1 — Add the Ruby gems

Add kaminari (pagination) and rack-cors (cross-origin requests) to the Gemfile:

Ruby
# Gemfile -- relevant additions only.
# Add these two gems to the Gemfile generated by `rails new orders-api --api`,
# then run `bundle install`.
# Pagination helpers for ActiveRecord.
# Provides .page(n).per(size) on any relation and exposes .total_count,
# which this recipe returns to Handsontable as `total_rows`.
gem "kaminari"
# Cross-origin request support.
# Lets the Rails API accept requests from the frontend dev server
# (for example, Vite on http://localhost:5173).
gem "rack-cors"

Install them:

Terminal window
bundle install

Why these two gems?

  • kaminari gives you a .page(n).per(size) query method on any ActiveRecord relation. It also exposes total_count, which you return to Handsontable as totalRows.
  • rack-cors lets the Rails API accept requests from a different origin than the frontend dev server. Without it, the browser blocks the requests before Rails sees them.

Step 2 — Generate the Order model

Use the generator to create the model, the migration, and a matching controller file:

Terminal window
rails generate model Order order_number:string customer:string status:string total:decimal
rails db:migrate

The generated migration adds id and created_at / updated_at columns automatically. The final schema has the six fields referenced throughout this recipe: id, order_number, customer, status, total, and created_at.

What’s happening:

  • Rails’ decimal type stores currency values without floating-point rounding. For production, specify precision and scale: total:decimal{10,2}.
  • The primary key id is auto-incremented by the database. It becomes the rowId value on the Handsontable side.
  • created_at is filled automatically by ActiveRecord on insert.

The server/order.rb file contains the minimal model with validations and a status enum:

Ruby
# app/models/order.rb
#
# Order model for the Order Management demo grid.
#
# Fields match the Handsontable column definitions:
# id - auto-increment primary key, used as rowId on the frontend
# order_number - short identifier shown in the first column
# customer - customer display name
# status - workflow state (pending, paid, shipped, delivered, cancelled)
# total - order total; DecimalField avoids floating-point rounding
# created_at - read-only timestamp filled on insert
class Order < ApplicationRecord
STATUSES = %w[pending paid shipped delivered cancelled].freeze
validates :order_number, presence: true, uniqueness: true
validates :customer, presence: true
validates :status, inclusion: { in: STATUSES }
validates :total, numericality: { greater_than_or_equal_to: 0 }
# Keeps the initial grid display predictable before the user sorts.
default_scope -> { order(created_at: :desc) }
end

Step 3 — Seed the database

Add realistic seed data in db/seeds.rb:

Ruby
# db/seeds.rb
#
# Run with: rails db:seed
#
# Inserts 50 realistic order rows. The check against existing data makes
# the seed idempotent -- running it twice does not duplicate rows.
if Order.exists?
puts "Database already seeded -- skipping."
else
customers = [
"Acme Corp", "Vertex Industries", "Harbor Goods", "Alpine Supply Co.",
"Summit Partners", "Meridian Analytics", "Riverstone Ltd.", "Orbit Logistics",
"Beacon Retail", "Northwind Traders"
]
statuses = %w[pending paid shipped delivered cancelled]
orders = 50.times.map do |i|
{
order_number: "ORD-#{(1000 + i).to_s.rjust(5, '0')}",
customer: customers.sample,
status: statuses.sample,
total: (50 + rand * 4950).round(2),
created_at: rand(120).days.ago
}
end
Order.insert_all!(orders)
puts "Seeded #{orders.size} orders."
end

Run it:

Terminal window
rails db:seed

The seed script inserts 50 orders across realistic statuses (pending, paid, shipped, delivered, cancelled). It checks whether data already exists, so running it twice does not duplicate rows.

Step 4 — Configure the routes

Open config/routes.rb and register the orders resource inside an api namespace:

Ruby
# config/routes.rb
#
# All routes are mounted under /api so the controller lives in Api::OrdersController
# and the JSON API is clearly separated from any server-rendered views.
#
# only: [:index] keeps the single built-in RESTful route (GET /api/orders)
# and replaces single-resource create/update/destroy with the batch endpoints
# below. Handsontable's dataProvider sends every mutation as an array in a
# single request, so batch endpoints fit its payload shape naturally.
Rails.application.routes.draw do
namespace :api do
resources :orders, only: [:index] do
collection do
# POST /api/orders/create_rows -- insert many rows in one request
post :create_rows
# PATCH /api/orders/update_rows -- update many rows in one request
patch :update_rows
# DELETE /api/orders/remove_rows -- delete many rows in one request
delete :remove_rows
end
end
end
end

What’s happening:

  • namespace :api prefixes all routes with /api and scopes the controller under Api::OrdersController. This keeps the API separate from any server-rendered views you might add later.
  • only: [:index] restricts the generated RESTful routes to GET /api/orders — standard per-resource create/update/destroy routes are replaced by the batch collection routes below.
  • collection do ... end registers three custom routes at the collection URL (/api/orders/...) instead of the detail URL (/api/orders/:id). Handsontable’s dataProvider sends every mutation as an array in a single request, so batch endpoints are what you want.

The three resulting routes are:

MethodURLController action
POST/api/orders/create_rowscreate_rows
PATCH/api/orders/update_rowsupdate_rows
DELETE/api/orders/remove_rowsremove_rows

Step 5 — Configure CORS

Create config/initializers/cors.rb:

Ruby
# config/initializers/cors.rb
#
# Allow cross-origin requests from the frontend dev servers.
# Without rack-cors the browser blocks the preflight OPTIONS request
# before Rails ever sees it.
#
# insert_before 0 puts this middleware at the very front of the stack
# so it runs before Rails' routing can reject a cross-origin preflight.
#
# Replace the dev-server origins with your actual production domain(s)
# before deploying. Never use `origins "*"` with credentialed requests.
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins "http://localhost:5173", # Vite dev server
"http://localhost:3000" # Create React App / Next.js dev server
resource "/api/*",
headers: :any,
methods: [:get, :post, :patch, :put, :delete, :options],
expose: ["Content-Type"]
# If the frontend sends credentials (cookies, Authorization headers),
# also set `credentials: true` here and pin `origins` to exact URLs --
# wildcards are not allowed with credentialed requests.
end
end

What’s happening:

  • insert_before 0 puts the middleware at the very front of the stack so it runs before Rails’ routing.
  • origins lists the URLs that are allowed to call the API. Replace these with your production domain before deploying.
  • resource "/api/*" scopes the CORS rule to the API endpoints only.
  • methods must include :options for browsers to complete preflight requests on non-simple methods (PATCH, DELETE).

Production note: Never use origins "*" in production. Pin the list to the exact domains that own your Handsontable deployment.

Step 6 — Decide on the case convention

Rails emits snake_case JSON by default (order_number, created_at). Handsontable’s column data keys must match whatever the server returns.

You have two practical options:

Option A — Keep snake_case (used in this recipe). Set data: 'order_number' on the matching Handsontable column. No transformation on either side.

Option B — Emit camelCase from Rails. Override serialization globally:

config/initializers/json_case.rb
class ActiveRecord::Base
def as_json(options = {})
super(options).deep_transform_keys { |key| key.to_s.camelize(:lower) }
end
end

Then use data: 'orderNumber' on the frontend and also translate the sort_prop and filters[][prop] values back to snake_case in the controller.

Why this matters: mixing conventions — Rails returning order_number while Handsontable columns declare data: 'orderNumber' — silently breaks pagination, sorting, and filtering. The grid renders empty cells and the server receives unknown column names. Pick one convention and stick to it.

The rest of this recipe uses Option A (snake_case everywhere).

Step 7 — Build the controller

Create app/controllers/api/orders_controller.rb:

Ruby
# app/controllers/api/orders_controller.rb
#
# API controller providing paginated, sortable, and filterable order data,
# plus three batch CRUD actions that match Handsontable's dataProvider
# payload shape.
#
# The standard RESTful routes would operate on single resources at a time.
# Handsontable sends all row mutations as arrays, so we expose dedicated
# collection endpoints: create_rows / update_rows / remove_rows.
module Api
class OrdersController < ApplicationController
# Whitelist of columns that are allowed to appear in `order()` and
# raw SQL fragments. Values that reach these positions are not quoted
# as bind parameters, so anything that is not validated is an
# SQL-injection risk. Keep this list tight.
SORTABLE_COLUMNS = %w[order_number customer status total created_at].freeze
# ------------------------------------------------------------------
# GET /api/orders
#
# Accepts:
# page, page_size -- pagination
# sort_prop, sort_order -- single-column sort
# filters[N][prop|value|condition] -- column filters
#
# Returns:
# { rows: [...], total_rows: 123 }
#
# The JSON shape is converted to { rows, totalRows } on the frontend
# (or you can override serialization globally to emit camelCase --
# see the recipe for the trade-offs).
# ------------------------------------------------------------------
def index
scope = Order.all
scope = apply_filters(scope)
scope = apply_sort(scope)
scope = scope.page(params[:page]).per(params[:page_size] || 10)
render json: {
rows: scope.as_json,
total_rows: scope.total_count
}
end
# ------------------------------------------------------------------
# POST /api/orders/create_rows
#
# Accepts the onRowsCreate payload: { rows: [ { ... }, ... ] }.
# Returns the created rows with their database-assigned ids so
# dataProvider can replace client-side placeholders in its row map.
# ------------------------------------------------------------------
def create_rows
payload = Array(params[:rows])
allowed = Order.column_names - %w[id created_at updated_at]
rows = Order.transaction do
payload.map do |row|
Order.create!(row.to_unsafe_h.slice(*allowed))
end
end
render json: { rows: rows.as_json }, status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { error: e.message }, status: :unprocessable_entity
end
# ------------------------------------------------------------------
# PATCH /api/orders/update_rows
#
# Accepts { rows: [ { id, changes: { ... } }, ... ] }.
# `changes` only contains the fields that the user edited.
# ------------------------------------------------------------------
def update_rows
payload = Array(params[:rows])
allowed = Order.column_names - %w[id created_at updated_at]
updated = Order.transaction do
payload.map do |row|
record = Order.find(row[:id])
changes = row[:changes].to_unsafe_h.slice(*allowed)
record.update!(changes)
record
end
end
render json: { rows: updated.as_json }
rescue ActiveRecord::RecordNotFound => e
render json: { error: e.message }, status: :not_found
rescue ActiveRecord::RecordInvalid => e
render json: { error: e.message }, status: :unprocessable_entity
end
# ------------------------------------------------------------------
# DELETE /api/orders/remove_rows
#
# Accepts { row_ids: [1, 2, 3] }.
# Uses delete_all so the whole batch runs in a single SQL statement.
# Returns 204 No Content on success.
# ------------------------------------------------------------------
def remove_rows
ids = Array(params[:row_ids])
Order.where(id: ids).delete_all
head :no_content
end
private
# Translate Handsontable's sort state into ActiveRecord's .reorder() call.
# Uses .reorder (not .order) so any default_scope ordering is replaced
# rather than appended, which guarantees a single deterministic sort.
# The hash form quotes the column name safely.
# Values that are not on the whitelist are silently ignored -- no SQL
# is generated for them.
def apply_sort(scope)
prop = params[:sort_prop]
order = params[:sort_order] == "desc" ? :desc : :asc
return scope unless SORTABLE_COLUMNS.include?(prop)
scope.reorder(prop => order)
end
# Translate Handsontable's filters[] param into chained .where calls.
# Each condition is validated against the same whitelist used for sort.
# Multiple conditions combine with AND.
#
# Conditions supported:
# contains / not_contains -- case-insensitive substring match (ILIKE)
# eq / neq -- exact match
# begins_with / ends_with -- case-insensitive prefix / suffix match
# gt / gte / lt / lte -- numeric comparisons
# empty / not_empty -- checks for NULL or empty string
def apply_filters(scope)
filters = params[:filters]
return scope if filters.blank?
Array(filters.values).each do |filter|
prop = filter[:prop]
value = filter[:value]
condition = filter[:condition].presence || "contains"
next unless SORTABLE_COLUMNS.include?(prop)
safe = Order.sanitize_sql_like(value.to_s)
scope = case condition
when "contains" then scope.where("#{prop} ILIKE ?", "%#{safe}%")
when "not_contains" then scope.where.not("#{prop} ILIKE ?", "%#{safe}%")
when "eq" then scope.where(prop => value)
when "neq" then scope.where.not(prop => value)
when "begins_with" then scope.where("#{prop} ILIKE ?", "#{safe}%")
when "ends_with" then scope.where("#{prop} ILIKE ?", "%#{safe}")
when "gt" then scope.where("#{prop} > ?", value)
when "gte" then scope.where("#{prop} >= ?", value)
when "lt" then scope.where("#{prop} < ?", value)
when "lte" then scope.where("#{prop} <= ?", value)
when "empty"
string_col?(prop) ? scope.where("#{prop} IS NULL OR #{prop} = ''") : scope.where(prop => nil)
when "not_empty"
string_col?(prop) ? scope.where.not("#{prop} IS NULL OR #{prop} = ''") : scope.where.not(prop => nil)
else scope
end
end
scope
end
def string_col?(prop)
Order.column_for_attribute(prop)&.type&.in?(%i[string text])
end
end
end

This single file implements paginated index, server-side sort and filter, and the three batch CRUD actions.

Whitelist sortable columns

Sort inputs that flow into order() reach the SQL ORDER BY clause. Treating them as raw strings is an SQL-injection risk. Whitelist them once at the top of the class (SORTABLE_COLUMNS in orders_controller.rb).

index — paginated list with sort and filter

See the index action in orders_controller.rb.

What’s happening:

  1. Start from Order.all. This builds a base ActiveRecord relation without hitting the database yet.
  2. apply_filters adds .where(...) clauses from the parsed Handsontable filter list.
  3. apply_sort adds an .order(...) clause if the request includes a whitelisted sort_prop.
  4. kaminari adds LIMIT and OFFSET via .page(n).per(size). The query is still not executed.
  5. as_json triggers the query and serializes results. total_count issues a separate SELECT COUNT(*) that Handsontable uses to size the paginator.

The response shape is exactly what dataProvider expects: { rows, total_rows }. The frontend maps total_rows to totalRows (see Step 9).

Sort helper

See apply_sort in orders_controller.rb.

What’s happening:

  • params[:sort_prop] comes directly from the frontend’s sort_prop= query param (see Step 9).
  • SORTABLE_COLUMNS.include?(prop) is the whitelist check. Any column not on the list is silently ignored — no SQL is generated for it.
  • scope.reorder(prop => order) uses the hash form of .reorder(), which ActiveRecord quotes safely. reorder is used instead of order because the Order model has a default_scope that sorts by created_at DESCreorder replaces that default, while order would append to it.
  • params[:sort_order] falls back to :asc unless the client explicitly sends desc. This prevents arbitrary SQL fragments (for example, created_at; DROP TABLE orders) from reaching the database.

Filter helper

Handsontable sends filters as an indexed structure:

?filters[0][prop]=status&filters[0][value]=shipped&filters[0][condition]=eq
?filters[1][prop]=total&filters[1][value]=100&filters[1][condition]=gte

Rails parses bracket-indexed params into a nested hash automatically. See apply_filters and string_col? in orders_controller.rb.

What’s happening:

  • The SORTABLE_COLUMNS check is reused as a filter whitelist. Column names that reach the raw SQL fragment (ILIKE, >=, etc.) must be validated against a fixed list.
  • sanitize_sql_like escapes LIKE metacharacters (%, _, \) in the user-supplied value so they are treated as literals, not wildcards.
  • ILIKE is PostgreSQL-specific. On SQLite or MySQL, use LIKE with a COLLATE clause or case-normalize the input.
  • empty/not_empty distinguish between string columns (check for NULL and blank string) and non-string columns (check for NULL only) via the string_col? helper.
  • Each condition rebinds scope, so multiple filters combine with AND. dataProvider does not send OR groups by default.

Batch CRUD actions

See create_rows, update_rows, and remove_rows in orders_controller.rb.

What’s happening:

  • create_rows — receives { rows: [...] }. Each row is inserted with create! inside a transaction, which raises on validation errors and rolls back all inserts if one fails. column_names - ['id', 'created_at', 'updated_at'] blocks the client from setting system-managed fields. Status 201 tells dataProvider the rows were created.
  • update_rows — receives { rows: [{ id, changes: { ... } }] }. changes.slice(*allowed) (where allowed also excludes id, created_at, updated_at) drops any unknown or system-managed keys so they never reach the ORM. All updates run inside a transaction. Returning the updated rows lets dataProvider reconcile its internal row map.
  • remove_rows — receives { row_ids: [1, 2, 3] }. delete_all issues a single DELETE ... WHERE id IN (...) statement, which is faster than deleting each row one by one. Status 204 signals a successful delete with no response body.

Step 8 — CSRF in API mode

ApplicationController in API mode inherits from ActionController::API, which does not include RequestForgeryProtection. That means CSRF verification is off by default for this project, and fetch() requests from the browser do not need an X-CSRF-Token header.

When does CSRF matter?

  • If you mount the Rails API under the same origin as a classic Rails app that uses cookie-based sessions, the classic app enforces CSRF protection — your fetch calls will need the token.
  • Token-based auth (for example, a Authorization: Bearer header) does not require CSRF protection because the browser never attaches the token automatically.

This recipe assumes a stateless API and an Authorization header (or no auth) in production. Add protect_from_forgery with: :null_session inside the controller only if you re-enable session-based auth.

Step 9 — Build the request URL on the frontend

Handsontable’s dataProvider calls fetchRows with { page, pageSize, sort, filters }. Map those to the Rails parameter names.

What’s happening:

  • pageSize is converted to page_size because Rails and kaminari use snake_case parameter names.
  • sort is split into two flat params, sort_prop and sort_order. The controller’s apply_sort reads them directly.
  • Filters are flattened from DataProviderFilterColumn[] (each with prop and conditions: [{name, args}]) into one bracket-notation entry per condition. Rails converts the bracket notation to a nested hash automatically.

Step 10 — Initialize Handsontable

Start the backend and the Vite dev server with bash setup.sh (or make setup), then open http://localhost:5173. The Rails API runs on http://localhost:3000 inside Docker; Vite proxies all /api/* requests to it. The complete frontend code is in the files below.

JavaScript
import { useCallback, useMemo, useRef } from 'react';
import { HotTable } from '@handsontable/react-wrapper';
import { registerAllModules } from 'handsontable/registry';
registerAllModules();
const API_BASE = '/api/orders';
// Serializes fetchRows query parameters into a URL the Rails controller understands.
//
// Handsontable sends:
// sort: { prop: 'order_number', order: 'asc' } or null
// filters: [{ prop, conditions: [{ name, args }] }] or null
//
// Rails reads:
// page_size, sort_prop, sort_order, filters[N][prop/condition/value]
function buildUrl(base, { page, pageSize, sort, filters }) {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
if (sort?.prop) {
params.set('sort_prop', sort.prop);
params.set('sort_order', sort.order ?? 'asc');
}
if (filters?.length) {
let idx = 0;
filters.forEach(({ prop, conditions }) => {
(conditions || []).forEach(({ name, args }) => {
if (!name) return;
params.set(`filters[${idx}][prop]`, prop);
params.set(`filters[${idx}][condition]`, name);
params.set(`filters[${idx}][value]`, args?.[0] ?? '');
idx++;
});
});
}
return `${base}?${params.toString()}`;
}
const ExampleComponent = () => {
const hotRef = useRef(null);
const removeConfirmedRef = useRef(false);
const fetchRows = useCallback(async ({ page, pageSize, sort, filters }, { signal }) => {
const url = buildUrl(API_BASE, { page, pageSize, sort, filters });
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
// Rails returns: { rows: [...], total_rows: n }
const json = await res.json();
return { rows: json.rows, totalRows: json.total_rows };
}, []);
const onRowsCreate = useCallback(async ({ rowsAmount }) => {
const newRows = Array.from({ length: rowsAmount }, () => ({
order_number: `ORD-${Date.now()}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`,
customer: 'New Customer',
status: 'pending',
total: 0,
}));
const res = await fetch(`${API_BASE}/create_rows`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows: newRows }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Create failed: ${res.status}`);
}
const json = await res.json();
const info = json.rows.map(r => `(order: ${r.order_number})`).join(', ');
hotRef.current?.hotInstance?.getPlugin('notification').showMessage({
variant: 'success',
title: 'Row added',
message: `Created: ${info}`,
duration: 3000,
});
return json.rows;
}, []);
const onRowsUpdate = useCallback(async (rows) => {
const res = await fetch(`${API_BASE}/update_rows`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rows: rows.map((r) => ({ id: r.id, changes: r.changes })),
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Update failed: ${res.status}`);
}
}, []);
const onRowsRemove = useCallback(async (rowIds) => {
const res = await fetch(`${API_BASE}/remove_rows`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ row_ids: rowIds }),
});
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
}, []);
const dataProvider = useMemo(
() => ({ rowId: 'id', fetchRows, onRowsCreate, onRowsUpdate, onRowsRemove }),
[fetchRows, onRowsCreate, onRowsUpdate, onRowsRemove]
);
// beforeRowsMutation is sync (checks for strict === false return).
// Cancel the first attempt, show a notification with Delete/Cancel actions,
// and on Delete re-issue the remove via the DataProvider API.
const beforeRowsMutation = useCallback((operation, payload) => {
if (operation === 'remove' && !removeConfirmedRef.current) {
const count = payload.rowsRemove.length;
const hot = hotRef.current?.hotInstance;
if (!hot) return false;
const notification = hot.getPlugin('notification');
const id = notification.showMessage({
variant: 'warning',
title: 'Delete rows',
message: `Delete ${count} row${count !== 1 ? 's' : ''}? This cannot be undone.`,
duration: 0,
actions: [
{
label: 'Delete',
type: 'primary',
callback: () => {
notification.hide(id);
removeConfirmedRef.current = true;
hot.getPlugin('dataProvider').removeRows(payload.rowsRemove).finally(() => {
removeConfirmedRef.current = false;
});
},
},
{
label: 'Cancel',
type: 'secondary',
callback: () => notification.hide(id),
},
],
});
return false;
}
}, []);
return (
<div>
<HotTable
ref={hotRef}
dataProvider={dataProvider}
beforeRowsMutation={beforeRowsMutation}
pagination={{ pageSize: 10 }}
columnSorting={true}
filters={true}
dropdownMenu={['filter_by_condition', 'filter_action_bar']}
contextMenu={true}
emptyDataState={true}
notification={true}
dialog={true}
colHeaders={['Order #', 'Customer', 'Status', 'Total', 'Created']}
columns={[
{ data: 'order_number', type: 'text' },
{ data: 'customer', type: 'text' },
{ data: 'status', type: 'text' },
{ data: 'total', type: 'numeric', numericFormat: { pattern: '$0,0.00' } },
{ data: 'created_at', type: 'date', dateFormat: 'YYYY-MM-DD', readOnly: true },
]}
rowHeaders={true}
height="auto"
licenseKey="non-commercial-and-evaluation"
/>
</div>
);
};
export default ExampleComponent;

Key options explained:

OptionWhat it does
rowId: 'id'Tells dataProvider which field uniquely identifies a row. Must match the Rails primary key name.
{ signal } in fetchRowsPass the AbortSignal to fetch() so in-flight requests are cancelled when the user sorts or filters before the previous response arrives.
{ rowsAmount } in onRowsCreatedataProvider passes the number of rows to add. The frontend builds default objects and sends them as { rows: [...] }. Returning json.rows lets dataProvider replace client-side placeholder ids with the ids assigned by Rails.
beforeRowsMutationIntercepts mutations before they run. Return false to cancel. Used here to show a delete-confirmation notification with Delete/Cancel actions instead of deleting immediately.
pagination: { pageSize: 10 }Enables the pagination toolbar. dataProvider passes the current page and size to fetchRows automatically.
columnSorting: trueEnables column header click-to-sort. The sort state is passed to fetchRows.
filters: true with dropdownMenuRenders the column filter UI. Active conditions are passed to fetchRows.
contextMenu: trueEnables right-click context menu with Insert row above / below and Remove row options.
emptyDataState: trueShows a friendly illustration when the API returns zero rows (for example, when a filter matches nothing).
notification: trueShows automatic error toasts when fetchRows or a mutation callback throws. Fetch failures include a Refetch action.
dialog: trueEnables the Dialog plugin used internally by other plugins for confirmation prompts.

How it works — Complete flow

  1. Initial load: dataProvider calls fetchRows({ page: 1, pageSize: 10 }). Rails returns the first 10 orders and the total row count.
  2. User clicks a column header: columnSorting updates its sort state and dataProvider calls fetchRows again with sort: { prop: 'total', order: 'desc' }. The controller’s apply_sort checks SORTABLE_COLUMNS, then issues order(total: :desc).
  3. User applies a column filter: Filters updates its condition list and dataProvider calls fetchRows with the filters array. The controller’s apply_filters parses the indexed hash and chains .where calls.
  4. User navigates to page 2: dataProvider calls fetchRows({ page: 2, pageSize: 10, ... }). kaminari returns rows 11-20.
  5. User edits a cell: dataProvider calls onRowsUpdate with [{ id: 7, changes: { total: 142.5 } }]. The frontend maps this to { id, changes } and sends it. update_rows applies the change inside a transaction.
  6. User adds a row: dataProvider calls onRowsCreate. create_rows inserts the row and returns it with the database-assigned id. dataProvider updates its row map so subsequent edits target the correct id.
  7. User deletes rows: dataProvider calls onRowsRemove([3, 7, 14]). remove_rows issues a single DELETE ... WHERE id IN (3, 7, 14).

What you learned

  • Rails API mode (rails new ... --api) skips middleware you do not need for a JSON API and leaves CSRF protection off by default.
  • kaminari adds .page(n).per(size) plus .total_count to any ActiveRecord relation — exactly what you need to build a { rows, total_rows } response.
  • Validate every column name that reaches order() or a raw SQL fragment against a fixed whitelist. Never trust params[:sort_prop] or params[:filters] directly.
  • Pick one case convention (snake_case or camelCase) for the whole round trip. Mixing conventions silently breaks pagination, sorting, and filtering.
  • Translate sort: { prop, order } on the frontend to flat sort_prop / sort_order query params. This matches Rails’ parameter-naming conventions and keeps the controller focused.
  • Handsontable’s DataProviderFilterColumn[] ({prop, conditions: [{name, args}]}) must be flattened into indexed filters[N][prop/condition/value] bracket params before sending. Rails parses the bracket notation into a nested hash automatically — no custom decoder is required on the backend.
  • Use rack-cors to allow requests from the frontend dev server. Place the middleware before 0 so it runs before Rails’ routing.

Next steps