Reorder List Items With Drag-n-Drop in React + DynamoDB App

January 25, 2023

5 min read

Reorder List Items With Drag-n-Drop in React + DynamoDB App

Let's implement a list reordering feature in a React app backed by a DynamoDB database using react-beautiful-dnd. Here I have my productivity app Increaser, where you can track daily habits for better sleep, mood or productivity. Recently some users asked for a drag-n-drop support to organize habit items by priority.

list

Order in DynamoDB

I store habits as an attribute of the user table item. Instead of using a list, I keep them as a record with the id as a key.

export type User = {
  // ...
  habits: Record<string, Habit>
}

That way, when the user changes a single habit in the app, we only need to update that specific record element rather than changing the whole list and causing a more expensive operation.

export const putHabit = async (userId: string, habit: Habit) => {
  await documentClient
    .update({
      ...getUserItemParams(userId),
      UpdateExpression: `set habits.#id = :habit`,
      ExpressionAttributeValues: {
        ":habit": habit,
      },
      ExpressionAttributeNames: {
        "#id": habit.id,
      },
    })
    .promise()
}

When the user drags the habit to a new position, we update only a single habit by changing its order attribute. This attribute doesn't have any meaning in a context of a single item and is only used to sort habits between each other. And here comes the front-end function we use to get a new habit order after moving an item from a position with the sourceIndex to a new one with destinationIndex.

export const getNewOrder = (
  orders: number[],
  sourceIndex: number,
  destinationIndex: number
): number => {
  const sourceOrder = orders[sourceIndex]
  if (orders.length === 1) {
    return sourceOrder
  }

  if (destinationIndex === 0) {
    return orders[0] - 1
  }

  if (destinationIndex === orders.length - 1) {
    return orders[destinationIndex] + 1
  }
  if (destinationIndex < sourceIndex) {
    return (
      orders[destinationIndex - 1] +
      (orders[destinationIndex] - orders[destinationIndex - 1]) / 2
    )
  }

  return (
    orders[destinationIndex] +
    (orders[destinationIndex + 1] - orders[destinationIndex]) / 2
  )
}

When we move the item to the first position, we assign its order to the smallest one minus one, and we apply a similar logic when moving to the end of the list. Since the order is a floating number, we can always increase it by half a distance between the neighboring items.

Moving Items with React-Beautiful-DnD

While there are other libraries for dragging elements, I've already used react-beautiful-dnd at a job, and it's designed specifically for moving items between lists.

import { useUpdateHabitMutation } from "habits/api/useUpdateHabitMutation"
import { useCallback } from "react"
import {
  DragDropContext,
  Droppable,
  OnDragEndResponder,
} from "react-beautiful-dnd"
import { getNewOrder } from "shared/utils/getNewOrder"
import styled from "styled-components"

import { CurrentHabitProvider } from "../CurrentHabitProvider"
import { useHabits } from "../HabitsProvider"
import { ActiveHabit } from "./ActiveHabit"

const Container = styled.div`
  > * {
    margin-top: 16px;
  }
`

export const ActiveHabitsList = () => {
  const { habits } = useHabits()

  const { mutate: updateHabit } = useUpdateHabitMutation()
  const handleDragEnd: OnDragEndResponder = useCallback(
    ({ destination, source, draggableId }) => {
      if (!destination) {
        return
      }

      if (
        destination.droppableId === source.droppableId &&
        destination.index === source.index
      ) {
        return
      }

      const order = getNewOrder(
        habits.map(({ order }) => order),
        source.index,
        destination.index
      )

      updateHabit({
        id: draggableId,
        order,
      })
    },
    [habits, updateHabit]
  )

  return (
    <DragDropContext onDragEnd={handleDragEnd}>
      <Droppable droppableId="habits">
        {(provided) => (
          <Container ref={provided.innerRef} {...provided.droppableProps}>
            {habits.map((habit) => (
              <CurrentHabitProvider value={habit} key={habit.id}>
                <ActiveHabit />
              </CurrentHabitProvider>
            ))}
            {provided.placeholder}
          </Container>
        )}
      </Droppable>
    </DragDropContext>
  )
}

We wrap the list with DragDropContext and provide callbacks. In the habit list, we only want to know about the drag end so we can call the API and update the list. Then we render Droppable and container for the habits. While we always use flexbox's gap attribute to create a space between items, the library doesn't support it, so we had to use margins.

import { Draggable } from "react-beautiful-dnd"
import styled, { css } from "styled-components"
import { defaultBorderRadiusCSS } from "ui/borderRadius"
import { HStack, VStack } from "ui/Stack"
import { Text } from "ui/Text"
import { EmojiTextPrefix } from "ui/Text/EmojiTextPrefix"

import { useCurrentHabit } from "../CurrentHabitProvider"
import { useHabits } from "../HabitsProvider"
import { useActiveHabits } from "./ActiveHabitsContext"
import { HabitAnalytics } from "./HabitAnalytics"
import { HabitProgress } from "./HabitProgress"
import { ManageHabit } from "./ManageHabit"

const Card = styled.div<{ isDragging?: boolean }>`
  ${defaultBorderRadiusCSS}
  background-color: ${({ theme }) => theme.colors.background.toCssValue()};
  padding: 20px;
  ${({ isDragging }) =>
    isDragging &&
    css`
      transform: rotate(4deg);
    `}
`

export const ActiveHabit = () => {
  const habit = useCurrentHabit()
  const { emoji, name, id } = habit
  const { isReadonly } = useActiveHabits()

  const { habits } = useHabits()

  const index = habits.indexOf(habit)

  const content = <div>habit content...</div>

  return (
    <Draggable index={index} draggableId={id}>
      {(provided, { isDragging }) => (
        <div
          ref={provided.innerRef}
          {...provided.draggableProps}
          {...provided.dragHandleProps}
        >
          <Card isDragging={isDragging}>{content}</Card>
        </div>
      )}
    </Draggable>
  )
}

We wrap the habit item with a Draggable component with two required parameters - id and index. Since we store the habits in the context, we don't need to provide the index as a property and get with the indexOf method. When dragging the item, we slightly rotate the card for a better UX. It's possible to show a customized placeholder like a shadow under the dragged element, yet it requires some extra work since react-beautiful-dnd doesn't provide such customization, and we have only a transparent placeholder.