ruby javascript erb 121 lines · 4 tabs

Reorder a list server-side and reflect instantly with Turbo Streams

Shared by codesnips Jan 2026
4 tabs
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
4 files · ruby, javascript, erb Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Reorder a list server-side and reflect instantly with Turbo Streams — share card
Link copied