class Task < ApplicationRecord
POSITION_GAP = 1024
belongs_to :board
scope :ordered, -> { order(:position) }
broadcasts_to ->(task) { [task.board, :tasks] },
target: ->(task) { "board_#{task.board_id}_tasks" },
partial: "tasks/resequenced_list",
locals: ->(task) { { board: task.board } }
def reposition!(before:, after:)
lower = before&.position
upper = after&.position
new_position =
if lower && upper
(lower + upper) / 2
elsif upper
upper - POSITION_GAP
elsif lower
lower + POSITION_GAP
else
POSITION_GAP
end
if lower && upper && (upper - lower) <= 1
board.rebalance!
reposition!(before: before&.reload, after: after&.reload)
else
update!(position: new_position)
end
end
end
class TasksController < ApplicationController
before_action :set_board
def move
task = @board.tasks.find(params[:id])
Task.transaction do
before = neighbour(params[:before])
after = neighbour(params[:after])
task.reposition!(before: before, after: after)
end
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"board_#{@board.id}_tasks",
partial: "tasks/resequenced_list",
locals: { board: @board }
)
end
format.json { head :no_content }
end
end
private
def set_board
@board = Current.user.boards.find(params[:board_id])
end
def neighbour(id)
return if id.blank?
@board.tasks.lock.find(id)
end
end
import { Controller } from "@hotwired/stimulus";
import Sortable from "sortablejs";
export default class extends Controller {
static values = { url: String };
connect() {
this.sortable = Sortable.create(this.element, {
handle: "[data-sortable-target='handle']",
animation: 150,
onEnd: (event) => this.persist(event.item)
});
}
disconnect() {
this.sortable.destroy();
}
persist(item) {
const id = item.dataset.taskId;
const before = item.previousElementSibling?.dataset.taskId ?? "";
const after = item.nextElementSibling?.dataset.taskId ?? "";
fetch(this.urlValue.replace(":id", id), {
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Accept": "text/vnd.turbo-stream.html",
"X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content
},
body: JSON.stringify({ before, after })
}).then((response) => {
if (response.ok) return Turbo.renderStreamMessage(response.text());
});
}
}
<%# renders one draggable row; the list frame is rebuilt by Turbo Streams %>
<div id="<%= dom_id(task) %>"
class="task-row"
data-task-id="<%= task.id %>">
<button type="button"
class="drag-handle"
data-sortable-target="handle"
aria-label="Reorder task">
⋮⋮
</button>
<span class="task-title"><%= task.title %></span>
<span class="task-position text-muted">#<%= task.position %></span>
</div>
This snippet shows how a drag-to-reorder list is persisted authoritatively on the server while the UI updates instantly, using Rails, a Stimulus controller wired to a SortableJS drop, and Turbo Streams to broadcast the result.
The core idea in Task model is that ordering is stored as an explicit integer position rather than being derived from row order, and positions are spaced out with a gap (POSITION_GAP) instead of being consecutive. Consecutive integers force a renumber of every row on each move; a gap lets reposition! slot a task between two neighbours by taking the midpoint of their positions, so a typical move touches exactly one row. When the midpoint collapses (no integer fits between two adjacent positions) the model calls rebalance! to restripe the whole list back to clean POSITION_GAP multiples. This is the classic trade-off: cheap moves most of the time, an occasional O(n) rebalance. The broadcasts_to declaration and resequenced_list fragment mean any change re-renders the ordered list for every subscriber over Turbo Streams.
In TasksController, the move action is the only write path. It runs inside a transaction and re-reads the neighbours with lock (a SELECT ... FOR UPDATE) so two people dragging the same list cannot compute midpoints from stale positions and produce duplicates. The before and after params identify the drop target by neighbour ids, which is more robust than sending a raw index because the client's view of indices can drift. The action responds with turbo_stream, replacing the list frame so the mover sees the same authoritative order everyone else receives via the broadcast.
sortable_controller.js connects SortableJS to the DOM. On onEnd it reads the dragged element and the ids of its new neighbours, then issues a fetch PATCH with the CSRF token. Crucially it does not mutate the DOM itself — the Turbo Stream response is the single source of truth, which keeps the optimistic drag and the server state from diverging.
_task.html.erb renders each row with a dom_id and a drag handle carrying data-sortable-target. Because both the controller response and the model broadcast target the same list frame, a reorder is reflected identically across tabs and users without any manual reconciliation.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.