The goal here is to create a board like Trello, where we have Lists and Tasks within lists.

We should be able to use drag-and-drop to:

  • change order of Tasks within a List
  • move Tasks between lists
  • change order of Lists
  • save changes

Here’s a demo of what final solution will look like:


1. Initial setup and gem Ranked-model #

ranked-model is superior to acts_as_list. You can check the gems docs to learn why.

# terminal
bundle add ranked-model
rails g migration AddRowOrderToListsAndTasks

These should allow null values

class AddRowOrderToListsAndTasks < ActiveRecord::Migration[7.0]
  def change
    add_column :lists, :row_order, :integer
    add_column :tasks, :row_order, :integer

Add RankedModel to List

# app/models/list.rb
  validates :name, presence: true
  has_many :tasks

  include RankedModel
  ranks :row_order

Add RankedModel to Task. Use with_same to correctly calculate row_order within a list

# app/models/task.rb
  validates :name, presence: true
  belongs_to :list

  include RankedModel
  ranks :row_order, with_same: :list_id

2. Install rails/request.js #

It will allow us to make HTTP requests from our Stimulus controllers into our rails controllers

bundle add requestjs-rails
./bin/rails requestjs:install

3. Install sortable-js #

./bin/importmap pin stimulus-sortable sortablejs

This stimulus controller will:

  • initialize SotrableJS on an element and allow to sort it’s children
  • send a request to a selected URL within your app with the params of a new row_order_position of an element (newIndex), and if the element was moved to another parent list - with a parent id (sortableListId)
  • a group param that enables us to move tasks within lists (should be set on tasks <ul>)
import { Controller } from "@hotwired/stimulus"
import { put } from "@rails/request.js";
import Sortable from 'sortablejs';

// Connects to data-controller="sortable"
export default class extends Controller {
  static values = {
    group: String

  connect() {
    this.sortable = Sortable.create(this.element, {
      onEnd: this.onEnd.bind(this),
      group: this.groupValue

  onEnd(event) {
    var sortableUpdateUrl = event.item.dataset.sortableUpdateUrl
    var newIndex = event.newIndex
    var sortableListId =
    put(sortableUpdateUrl, {
      body: JSON.stringify({row_order_position: newIndex, list_id: sortableListId}),

4. Controller setup to sort tasks and lists #

sortableUpdateUrl will have to lead to either sort_task_path(task) or sort_list_path(list)

# config/routes.rb
  resources :tasks do
    member do
      put :sort
  resources :lists do
    member do
      put :sort

.rank(:row_order) sorts elements by their rank (as opposed to created_at or name).

Through in the database we store row_order, the update param should be row_order_position according to the RankedModel docs.

# app/controllers/lists_controller.rb
  def index
    @lists = List.rank(:row_order)

  def sort
    @list = List.find(params[:id])
    @list.update(row_order_position: params[:row_order_position])
    head :no_content

  def list
    @tasks = @list.tasks.rank(:row_order)

To update the task we will also need the list_id, because tasks can be moved between lists.

# app/controllers/tasks_controller.rb
  def sort
    @task = Task.find(params[:id])
    @task.update(row_order_position: params[:row_order_position], list_id: params[:list_id])
    head :no_content

Finally, integrate the stimulus controller into the views: #

Initialize sortable stimulus controller with data-controller="sortable".

Set the update path that should be triggered when an item gets sorted with data-sortable-update-url="<%= sort_list_path(list) %>".

<!-- app/views/lists/index.html.erb -->
<div id="lists">
  <ul data-controller="sortable" style="display:flex;">
    <% @lists.each do |list| %>
      <li data-sortable-update-url="<%= sort_list_path(list) %>" style="border:solid; width: 400px">
        <%= render list %>
    <% end %>

Within a sortable list we have sortable tasks.

If you allow moving tasks within lists, data-sortable-group-value="tasks" specifies that tasks can be moved only to lists that have the same group name.

data-sortable-id="<%= %>" will let your stimulus controller access the ID of the list to which the item was moved.

<!-- app/views/lists/_list.html.erb -->
<div id="<%= dom_id list %>">
  <%= %>
  <%= %>
  <%= list.row_order %>

  <ul data-controller="sortable" data-sortable-group-value="tasks" data-sortable-list-id="<>">
    <% list.tasks.rank(:row_order).each do |task| %>
      <li data-sortable-id="<%= %>" data-sortable-update-url="<%= sort_task_path(task) %>">
        <%= render task %>
    <% end %>

Just in case, here’s a very basic task partial:

<!-- app/views/tasks/_task.html.erb -->
<div id="<%= dom_id task %>">
    <%= %>
    <%= task.row_order %>

Final result:


And that’s it! Now we have advanced sorting functionality for a very small price.