Dashboard

How to Add Drag-and-Drop Reordering to an AI-Built App

A position column with fractional indexing lets you persist drag-and-drop order with one write per move, instead of renumbering the whole list and racing concurrent edits.

Steve Jefferson
Steve Jefferson
Developer Advocate
14 September 20261 min read

How to Add Drag-and-Drop Reordering to an AI-Built App

Drag-and-drop reordering to an AI-built app looks simple in a demo and falls apart the first time two people reorder the same list at once, or the list gets long enough to paginate. The fix has nothing to do with your drag library. It comes down to how you store order in the database: a position column that supports inserting a row between two neighbors without touching every other row. Get that one decision right and the drag handler on the frontend is the easy part.

Most AI app builders, including Swarmz, generate lists with an implicit order: whatever order rows come back from the database, usually by created_at or a plain auto-incrementing id. That works until a user wants to manually rearrange a task list, a roadmap board, or a set of pricing tiers, at which point you need a real, persisted, user-controlled order.

Why naive reordering breaks under real use

The obvious first implementation is an integer position column: 1, 2, 3, and so on. When a user drags row 5 to position 2, you renumber every row from 2 through 5. It works in a demo with ten rows and one user. It breaks in three specific ways once real traffic hits it.

  • A single drag now writes N rows instead of 1, so reordering a 200-item list means 200 writes for one mouse-up event.

  • Two people reordering the same list within a second or two of each other produce a write race: whichever renumbering transaction commits last silently overwrites the other's intent, and neither user sees an error.

  • Every renumber invalidates any cached or optimistic client state for rows the dragging user never touched, so other open tabs flicker or briefly show the wrong order.

The fix: fractional indexing instead of renumbering

Fractional indexing (sometimes called a lexorank or order key) assigns each row a numeric position that leaves room between neighbors. Inserting a row between position 1 and position 2 gives it position 1.5. Inserting again between 1 and 1.5 gives it 1.25. A drag only ever needs to write one row, the row that moved, regardless of how many rows sit around it.

Worked example: a Postgres-backed reorderable list

Here's the schema and the logic for a task list where tasks belong to a board and can be dragged into any order within it.

sql
-- Add a numeric position column, not an integer
alter table public.tasks
  add column position numeric not null default 0;

-- Index it per board so lookups for "give me the neighbors" stay fast
create index tasks_board_position_idx
  on public.tasks (board_id, position);

On drag end, the client sends the id of the row that moved and the ids of its new immediate neighbors. The server looks up their positions and computes a new value between them.

javascript
async function positionBetween(boardId, beforeId, afterId) {
  const before = beforeId ? await getPosition(beforeId) : 0;
  const after = afterId ? await getPosition(afterId) : before + 65536;
  return (before + after) / 2;
}

async function moveTask(taskId, boardId, beforeId, afterId) {
  const newPosition = await positionBetween(boardId, beforeId, afterId);
  await db.query(
    'update tasks set position = $1 where id = $2',
    [newPosition, taskId]
  );
}

Starting new rows 65536 apart, rather than 1 apart, matters. It gives you roughly sixteen successive insertions between the same two neighbors before the gap gets small enough to worry about, since each insertion halves the remaining space. Start at 1 apart and you run out of usable gap after three or four drags into the same spot.

Use numeric, not float, for the position column

A single-precision or double-precision float column will eventually round two distinct positions to the same value after enough successive insertions into the same gap, because floating point only carries about 15 to 17 significant decimal digits. Postgres' numeric type doesn't have that ceiling since it stores exact decimal digits rather than a binary approximation. See the PostgreSQL numeric type documentation for the precision and storage tradeoffs before you pick a column type.

The common mistake: relying on array index in frontend state

The second common failure mode has nothing to do with the database. It's storing order only as array index in client state, updated optimistically on drag, and never persisting a position value to the server at all, or persisting it as a fire-and-forget call with no response handling. The list looks reordered instantly, which feels correct in testing. Refresh the page, open it in a second tab, or lose the network request silently, and the order reverts or diverges between sessions. Treat the optimistic client reorder as a rendering hint only. The database row is the source of truth, and every drag has to complete a round trip that either confirms the new position or rolls the UI back.

Rebalancing when positions run out of room

Even with numeric instead of float, repeatedly dragging items into the same narrow gap eventually produces positions with more decimal digits than you want to carry around, or, if you started with integers and small gaps, literally no room left between two neighbors. Handle it with a periodic rebalance rather than trying to prevent it entirely.

  1. Detect it cheaply: check whether after - before falls below a small threshold, like 0.0001, at write time.

  2. When it does, don't block the user's drag. Complete their move with whatever precision is left, then queue a background rebalance for that board.

  3. The rebalance job reads every row for the board ordered by current position and rewrites them evenly spaced, for example 0, 65536, 131072, and so on, in a single transaction.

  4. Because the rebalance only changes the numeric position values and not the logical order, it's invisible to users, no rows appear to move.

Picking a drag-and-drop library

The library choice matters less than the position logic above, but it's worth being deliberate. The native HTML Drag and Drop API works without a dependency but has rough edges on touch devices and needs manual work for auto-scroll and drop indicators. Most production apps reach for a small library that handles pointer and touch events, keyboard-accessible reordering, and drop animations, and then wire its onDragEnd callback straight into the moveTask function above. Whichever library you pick, the callback should give you the moved item's id plus its new immediate neighbors, which is exactly the input the position calculation needs.

Testing reorder logic before you ship it

  • Drag an item to the very top and very bottom of a list and confirm the neighbor lookup handles a missing before or after id without throwing.

  • Fire two moves against the same two neighbors back to back and confirm both land at distinct, correctly ordered positions rather than colliding.

  • Reorder the same item into the same gap twenty or thirty times in a script and confirm your rebalance threshold actually triggers before precision runs out.

  • Reload the page after a drag and confirm the persisted order, not the optimistic client order, is what renders.

  • If the list is paginated, drag an item to the boundary between two pages and confirm the position value still sorts correctly across the page break.

Where reordering fits with the rest of your app

Reordering usually shows up after you've already settled the underlying data model, since a position column is one more thing to plan for on any list-backed table. If you haven't locked that down yet, planning your data model before building with AI covers how to decide on columns like this before you're retrofitting them onto live data. Two of the most common places reordering shows up first are an internal admin dashboard where staff manually prioritize a queue, and a public roadmap page where you rank upcoming features by hand. If you're earlier than either of those and still shaping the app itself, this complete guide to building an app with AI is the better starting point. And once the app is far enough along to put in front of customers, moving an AI-built app to a custom domain covers the DNS and OAuth callback details you'll run into next.

Frequently asked questions

What's the best way to store order in a database?

A single numeric position column per row, scoped to whatever the list is grouped by, such as a board or folder. Fractional indexing lets you insert a row between two existing ones by writing only that one row, instead of renumbering every row after it.

Should I use an integer or a float for the position column?

Neither, ideally. Use a numeric or decimal column. Integers force you to leave artificial gaps upfront and run out of room fast. Floats eventually lose precision after enough successive insertions into the same gap. Numeric avoids both problems.

Do positions need to be rebalanced eventually?

Yes, if the same gap gets used repeatedly. It's rare in practice for most lists, but a periodic background job that evenly re-spaces every row in a group is cheap insurance and should run without any visible effect on the UI.

Does drag-and-drop reordering work with pagination or infinite scroll?

Yes, as long as position is a real sortable column and your query orders by it directly. The one thing to test explicitly is dragging an item across a page boundary, since the neighbor on the other side of the boundary isn't loaded in the client yet.

Can an AI app builder generate drag-and-drop reordering automatically?

Most AI app builders can scaffold the drag interaction and a basic position column from a prompt, but they don't reliably generate the fractional indexing math or the rebalance job on their own. Those are worth asking for explicitly, or adding by hand using the schema and functions above.

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.