all posts

Drag and drop reordering in Rails: acts_as_list, Stimulus, and the parts that break

The setup every tutorial shows you, then the seven bugs it ships with: page 2 positions, missing scopes, two people dragging at once, a screen that lies, rows you can't drag, default_scope, and anyone being able to reorder.

Adrian Marin · Author & CEO

2,793 words 14 min read

Every app I worked on has a list that somebody wants in a very specific order. Carousel slides. Menu items. The steps of an onboarding flow. The plans on a pricing page.

It always starts the same way. "Can I just drag them around?" Sure you can. You add a position column, install acts_as_list, drop in SortableJS and it's working before lunch.

I'm saying "working", but what I really mean is "working until the first customer touches it". Because then somebody drags a row and the order is wrong. Not every time. Just sometimes.

We do this on our own pricing page. There are 23 add-ons and 48 plans on it, and we order both by dragging rows in our Avo app. The add-on doing the work is Record Reordering, and it sits at position 22 of 23 (yes, it reorders itself 🫣).

If you're in a hurry, here's the gist: an integer position column, acts_as_list scoped to the parent, SortableJS in a Stimulus controller and a PATCH action that calls insert_at inside a lock on the parent.

Below I go through that setup, then through the seven things that break once folks start using it, and how to fix each one. Most of the fixes are already inside our add-on, so I'll point you to where.

The setup

I'll use a Slide that belongs to a Carousel. It's the same shape as menu items in a CMS or tasks in a to-do list.

1. Add a position column and backfill it

bin/rails g migration AddPositionToSlides position:integer
class AddPositionToSlides < ActiveRecord::Migration[8.1]
  def change
    add_column :slides, :position, :integer

    reversible do |dir|
      dir.up do
        execute <<~SQL
          UPDATE slides SET position = ranked.rank
          FROM (
            SELECT id, ROW_NUMBER() OVER (PARTITION BY carousel_id ORDER BY created_at, id) AS rank
            FROM slides
          ) AS ranked
          WHERE slides.id = ranked.id
        SQL
      end
    end

    add_index :slides, [:carousel_id, :position], unique: true
  end
end

Don't skip the backfill. acts_as_list gets confused when some rows have a position and some are NULL, and on the first move you'll see records jumping to weird places. PARTITION BY carousel_id gives every carousel its own 1, 2, 3, which is what the scope from the next step expects.

The backfill also makes the unique index safe to add. With that index in place, the database just refuses two slides at the same position. And acts_as_list is smart about it: when it sees a unique index it switches to updating rows one by one (sequential_updates), so it doesn't trip over it.

(This SQL is PostgreSQL. On MySQL do it in Ruby with each.with_index(1) and update_column.)

2. Add acts_as_list with a scope

# Gemfile
gem "acts_as_list"
class Slide < ApplicationRecord
  belongs_to :carousel
  acts_as_list scope: :carousel
end

class Carousel < ApplicationRecord
  has_many :slides, -> { order(:position) }, dependent: :destroy
end

Now every slide gets move_higher, move_lower, move_to_top, move_to_bottom and insert_at(n). New slides go to the bottom of their own carousel and positions start at 1.

A small trap I want to warn you about: don't add validates :position, presence: true. acts_as_list sets the position after validations run, so a new slide fails a validation it would pass a millisecond later. The README mentions it, but it's very easy to miss.

acts_as_list has been around forever. It has more than 76 million downloads on RubyGems (October 2026), so whatever weird case you hit, someone else hit it before you.

Also notice that the order lives on the association and not in a default_scope on Slide. I'll come back to that one.

3. A route and a controller action

# config/routes.rb
resources :carousels do
  resources :slides do
    patch :move, on: :member
  end
end
class SlidesController < ApplicationController
  def move
    carousel = Carousel.find(params[:carousel_id])

    carousel.with_lock do
      slide = carousel.slides.find(params[:id])
      slide.insert_at(params.require(:position).to_i)
    end

    head :no_content
  end
end

The controller finds the slide through its carousel, so nobody can move a slide that isn't part of the list they're looking at. The slide is also loaded inside with_lock, so its position is fresh. The lock is important too, but more on that in #3 below.

4. Drag and drop with SortableJS and Stimulus

yarn add sortablejs @rails/request.js
# or with importmaps:
bin/importmap pin sortablejs @rails/request.js
// app/javascript/controllers/sortable_controller.js
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
import { patch } from "@rails/request.js"

export default class extends Controller {
  static values = { topPosition: { type: Number, default: 1 } }

  connect() {
    this.sortable = Sortable.create(this.element, {
      handle: "[data-sortable-handle]",
      onUpdate: this.save,
    })
  }

  disconnect() {
    this.sortable?.destroy()
  }

  save = async ({ item, newIndex }) => {
    this.sortable.option("disabled", true)
    this.element.classList.add("opacity-50")

    try {
      await patch(item.dataset.moveUrl, {
        body: { position: this.topPositionValue + newIndex },
      })
    } finally {
      this.element.classList.remove("opacity-50")
      this.sortable.option("disabled", false)
      window.Turbo.visit(window.location.href, { action: "replace" })
    }
  }
}
<%# app/views/slides/index.html.erb %>
<ul data-controller="sortable"
    data-sortable-top-position-value="<%= @slides.first&.position || 1 %>">
  <% @slides.each do |slide| %>
    <li id="<%= dom_id(slide) %>" data-move-url="<%= move_carousel_slide_path(@carousel, slide) %>">
      <span data-sortable-handle class="cursor-grab">⠿</span>
      <%= slide.title %>
    </li>
  <% end %>
</ul>

If you're on Turbo 8, add <%= turbo_refreshes_with method: :morph, scroll: :preserve %> in your layout's <head>. That way the reload after each drop morphs the page in place and doesn't throw you back to the top.

@rails/request.js sends the CSRF token for you, so the patch call just works.

And that's the happy path! This is pretty much where most tutorials stop.

What breaks

1. The DOM index is not the database position

SortableJS gives you newIndex, which is the row's index inside the list element, starting from 0. Most examples send newIndex + 1 to the server and move on. The popular stimulus-sortable component does the same.

That works on page 1. On page 2, with 25 per page, the first row on screen is position 26, and you just told the server to move it to position 1. The record flies to the top of the whole list and the user has no idea why.

So send a database position, not a screen position. Take the position of the first record you rendered and add the drop index to it. That's what data-sortable-top-position-value from above is for.

We read it from the record itself instead of computing (page - 1) * per_page + 1. A list with a gap in it (a deleted row nobody cleaned up) makes that math wrong, but the record's own position is always right.

Same trap if you have other things inside the list, like a header row or an empty state. newIndex might count those too, so keep only rows in the sortable element.

2. Positions without a scope

Forget the scope: :carousel and acts_as_list will treat every slide in the table as one big list. You drag slide 3 to the top of carousel A and slide 3 of carousel B moves down one. Nothing crashes, so you won't notice for a while.

If the list is scoped by something that's not an association, pass an array: acts_as_list scope: [:carousel_id, :locale]. You can even leave soft-deleted rows out: scope: [:carousel_id, deleted_at: nil].

3. Two people dragging at the same time

insert_at reads positions and then updates a range of rows to make room. If two people do that on the same list at the same moment, both read the same "before" state and you end up with two slides at position 4.

The acts_as_list README talks about this. First it recommends the unique index from step 1 ("rely on your database"), and then, in the section about deadlocks, locking the parent record.

That's why the controller has carousel.with_lock. Moves on one carousel run one after the other, so the second drag waits a few milliseconds for the first one. For an admin list you won't feel it.

One catch though. The lock only helps if it wraps every change to the list, so creating and destroying slides need it too, not just move. And with the unique index underneath, the worst case is a failed request, not a broken list.

If you're starting a new app, have a look at positioning. It's a newer gem from the same maintainer as acts_as_list (and ranked-model). It encourages a unique constraint on the position column, so the database refuses the duplicate instead of you finding it three months later, and it locks the list for you while it shuffles.

It also does relative moves (position: { after: other_slide }), which fits drag and drop very well. You send the id of the row you dropped it next to, and pagination is not your problem anymore.

4. The UI shows an order the server never saved

SortableJS moves the row in the DOM the moment you drop it. The request comes after. If the request fails, or the user drops a second row before the first request is done, the screen and the database don't agree anymore. And the next drop sends a position computed from rows that aren't where they look.

We do two things about it. While the request is in flight, the list is disabled and dimmed, so you can't start a second drag. When it's done (failed or not) we reload the list from the server, so the screen shows what the database has.

It costs one extra request, but the list can't drift.

5. You can't drag a row onto page 3

Drag and drop only reaches the rows you see on screen. If the slide you want at the top is on page 4, dragging won't help you.

And some folks can't drag at all. Someone on a keyboard or on a switch device needs another way. WCAG covers both cases: 2.1.1 (Keyboard), and 2.5.7 (Dragging Movements) from WCAG 2.2, which says anything you can do by dragging should also work with a single click or tap.

So keep buttons next to the handle. move_to_top and move_to_bottom fix the "it's on another page" problem in one click, and move_higher / move_lower give keyboard users a way in. You already have all of them from acts_as_list.

6. The default_scope trap

It's tempting to write default_scope { order(:position) } on the model so every list comes sorted. Don't do it. It leaks into every query in your app, including Slide.where(...).update_all, and it surprises the next person who adds order(created_at: :desc) and still gets position first.

Order where you list things: on the association (has_many :slides, -> { order(:position) }) or in the controller. Add a tie-breaker too, order(:position, :id), so two rows with the same position (it happens, see #3) always come back in the same order. Our Addon.ordered scope does just that.

7. Anyone can reorder it

Reordering changes what your customers see. On our pricing page, a wrong drag moves an add-on in front of every visitor. So treat the move action like any other update and authorize it. With Pundit it's one more policy method, reorder?, that usually just returns edit?.

When integer positions aren't enough

For an admin list, integer positions are the way to go. They're boring and I like boring. You open the table and you see 1, 2, 3.

But every insert_at rewrites the positions of all the rows between the old spot and the new one. Move the last item of a 10,000 row list to the top and that's 10,000 updated rows.

If you have very long lists, or a lot of people editing the same list live (think Figma layers or the Jira backlog), look at fractional indexing. Each item gets a sortable key that sits between its neighbours, so a move updates exactly one row. Figma wrote about how they do it back in 2017, and Jira uses something called LexoRank.

None of them is free. The keys get longer the more people reorder, and you still need a rebalance job for when they get too long. ranked-model sits somewhere in the middle with spread-out integers.

I wouldn't start there though. I'd start with acts_as_list, a scope and a lock, and switch only the day a list gets big enough to hurt.

Or skip the plumbing in your Avo app

If the list lives in your admin, you don't have to build any of this. The Record Reordering add-on adds the move buttons and drag and drop to your resource Index view, and every action is a lambda that calls your own acts_as_list methods. This is the actual config we use for our add-ons list:

class Avo::Resources::Addon < Avo::BaseResource
  self.index_query = -> { query.ordered }

  self.ordering = {
    drag_and_drop: true,
    visible_on: :index,
    display_inline: true,
    actions: {
      insert_at: -> { record.insert_at position },
      to_top: -> { record.move_to_top },
      to_bottom: -> { record.move_to_bottom }
    }
  }
end

It already does the things from this post. It sends the first record's position plus the drop index (so page 2 works), it disables and dims the list while saving, it reloads the frame from the server after, and it hides the controls when your reorder? policy says no.

Switch visible_on to :association and you reorder slides inside one carousel's page instead. Pair it with Authorization for the reorder? rule. And if your list is more of a board than a list, Kanban does drag and drop across columns. (We wrote about how we modeled the position of a card on a Kanban board with delegated types, if you're curious how that one works.)

All the options are in the record reordering docs.

If there's one bug I'd want you to remember from this post, it's the duplicate position one. Nothing crashes. The order is just a bit off and nobody knows since when.

Stay sane, keep your lists in order ✌️

FAQ

How do I make a list sortable with drag and drop in Rails?

Add an integer position column, add acts_as_list scoped to the parent (acts_as_list scope: :carousel) and expose a PATCH member route that calls record.insert_at(position) inside parent.with_lock. On the front-end, attach SortableJS to the list with a Stimulus controller and send the new position to that route from the onUpdate callback.

Should I use acts_as_list, positioning, or ranked-model?

acts_as_list is the default for a long time now and works well for most admin lists. positioning comes from the same maintainer. It's newer, it encourages a unique constraint on the position column and it supports relative moves like before/after. ranked-model stores spread-out integer ranks so most moves touch fewer rows. For a new project I'd look at positioning. For an existing one, acts_as_list is fine.

Why does my reordered record jump to the wrong place on page 2?

Because SortableJS gives you the row's index on screen, starting from 0, and on page 2 the first row is not position 1. Send the first rendered record's position plus newIndex instead of newIndex + 1.

How do I stop duplicate positions when two users reorder at once?

Add a unique index on [parent_id, position] so the database rejects duplicates. acts_as_list switches to one-by-one updates when it sees that index, and positioning is built around it. Then wrap every change to the list in a lock on the parent record, like carousel.with_lock { slide.insert_at(position) }, so changes to the same list run one at a time.

Should I use default_scope to order by position?

No. A default_scope applies to every query on the model and it's easy to forget it's there. Put the order on the association, like has_many :slides, -> { order(:position) }, or in the query that renders the list, and add :id as a tie-breaker.

Join the newsletter

Rails guides, Avo releases, and useful lessons from building the admin layer.

No noise. Unsubscribe whenever you like. Read our privacy policy.