Sort It Out

Rich BurkeRich Burke
5 min read

Sortable lists are extremely useful and extremely common in web applications. Let's build one in React Router. We'll use the powerful and popular dnd kit, with a dash of shadcn/ui.

A summarized task list for accomplishing this:

In case you'd just like to add a sortable list to your app as quickly as possible, the finished code can be found in this repo. And here are the relevant files:

  • react-router.config.ts

  • app/types/item.ts

  • app/lib/utils.ts

  • app/components/draggable-card.tsx

  • app/components/sortable-region.tsx

  • app/welcome/welcome.tsx

The walkthrough

As we walk through the setup, I'll include links to additional reference reading. Those references look like this: (* React Router).

npx create-react-router@latest sortable-list-app

Choose "Yes" when asked to install dependencies. After that we'll get some of the other pieces in place.

cd sortable-list-app

npm install @dnd-kit/core
npm install @dnd-kit/sortable
npx shadcn@latest init
npx shadcn@latest add card
npm run dev

We'll need to render aspects of our list on the client. So update react-router-config.ts to look like the following (* Client side rendering).

// react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  // Config options...
  // Server-side render by default, to enable SPA mode set this to `false`
  ssr: false, // <- This is the change.
} satisfies Config;

Add the following to app.css, in the @layer base section (* the @layer directive ).

# app/app.css
  h1 {
    @apply font-bold;
    font-size: 3rem;
  }

Create a directory named app/types and add a file named item.ts.

// app/types/item.ts
export type Item = {
  id: number;
  color: string;
  title: string;
  description: string;
};

Add the following functions to app/lib/utils.ts.

// app/lib/utils.ts
...
export function isMatchForId<T>(targetId: T) {
  return function(test: { id: T }) {
    return targetId === test.id;
  }
}

export function getById<T>(a: {id: T}[]) {
  return function(id: T): {id: T} | undefined {
    return a.find(obj => obj.id === id);
  }
}

Let's start building out the elements that will handle the actual sort. In app/components, create a file named draggable-card.tsx. The DraggableCard component will wrap the shadcn-ui Card component that we imported, adding draggability and sortability (* useDraggable and useSortable ).

// app/components/draggable-card.tsx
import type { HTMLProps } from "react";
import { useDraggable } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import type { Item } from "~/types/item";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";

type DraggableCardType = Item & {
  className?: HTMLProps<HTMLElement>["className"];
};

export function DraggableCard({
  id,
  color,
  title,
  description,
  className,
}: DraggableCardType) {
  const { isDragging } = useDraggable({
    id: id,
  });
  const { attributes, listeners, setNodeRef, transform, transition } =
    useSortable({ id });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };

  return (
    <Card
      ref={setNodeRef}
      style={style}
      {...attributes}
      {...listeners}
      className={cn(
        className,
        "min-w-fit",
        `${isDragging ? "cursor-grabbing" : "cursor-grab"}`,
        color
      )}
    >
      <div className="flex items-center justify-start px-4 py-2 w-full">
        <GripIcon className="scale-100" />
        <div className="w-100">
          <CardHeader>
            <CardTitle className="leading-[1.3]">{title}</CardTitle>
          </CardHeader>
          <CardContent>
            <p className="leading-[1.2]">{description}</p>
          </CardContent>
        </div>
      </div>
    </Card>
  );
}

Next let's define a region that will contain our sortable cards. Create a file app/components/sortable-region.tsx (* DragOverlay ).

// app/components/sortable-region.tsx
import { useState } from "react";
import { closestCenter, DndContext, DragOverlay } from "@dnd-kit/core";
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { DraggableCard } from "~/components/draggable-card";
import { getById, isMatchForId } from "~/lib/utils";
import type { Item } from "~/types/item";

export function SortableRegion({ initial_items }: { initial_items: Item[] }) {
  const [items, setItems] = useState<Item[]>(initial_items);
  const [activeId, setActiveId] = useState<number | null>(null);

  const getItem = getById(items);

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event;

    if (!over) {
      setActiveId(null);
      return;
    }

    if (active.id !== over.id) {
      setItems((items) => {
        const oldIndex = items.findIndex(isMatchForId(active.id));
        const newIndex = items.findIndex(isMatchForId(over.id));

        return arrayMove(items, oldIndex, newIndex);
      });
    }
    setActiveId(null);
  }

  function handleDragStart(event: DragStartEvent) {
    setActiveId(event.active.id as number);
  }

  return (
    <div className="flex flex-col gap-4 rounded-lg p-4 border">
      <DndContext
        collisionDetection={closestCenter}
        onDragStart={handleDragStart}
        onDragEnd={handleDragEnd}
      >
        <SortableContext items={items} strategy={verticalListSortingStrategy}>
          {items.map((item) => {
            return (
              <DraggableCard
                key={item.id}
                {...item}
                className={item.id === activeId ? "opacity-30" : "opacity-100"}
              />
            );
          })}
        </SortableContext>

        <DragOverlay>
          {(() => {
            if (activeId) {
              const activeItem = getItem(activeId) as Item;
              if (activeItem) {
                return (
                  <DraggableCard
                    key={activeId}
                    {...activeItem}
                    className="opacity-75"
                  />
                );
              }
            }
            return null;
          })()}
        </DragOverlay>
      </DndContext>
    </div>
  );
}

Finally we'll update app/welcome/welcome.tsx to include our new sortable region, supplying the region some items to sort.

// app/welcome/welcome.tsx
import { SortableRegion } from "~/components/sortable-region";

const INITIAL_ITEMS = [
  {
    id: 1,
    title: "Chapter 12: Quantifying Uncertainty",
    description: "Artificial Intelligence: A Modern Approach",
    color: "bg-green-100",
  },
  {
    id: 2,
    title: "Chapter 13: Probabilistic Reasoning",
    description: "Artificial Intelligence: A Modern Approach",
    color: "bg-yellow-100",
  },
  {
    id: 3,
    title: "Chapter 12: Planning and Uncertainty",
    description: "Artificial Intelligence: Foundations of Computational Agents",
    color: "bg-cyan-100",
  },
  {
    id: 4,
    title: "Think more rationally with Bayes' rule",
    description: "YouTube, Steven Pinker",
    color: "bg-pink-100",
  },
  {
    id: 5,
    title: "Probability and Statistics Refresher",
    description: "YouTube, XP Actuarial",
    color: "bg-orange-100",
  },
];

export function Welcome() {
  return (
    <div className="flex flex-col items-center justify-items-start pt-8 pb-4">
      <header className="mb-8">
        <h1>Learning Path</h1>
      </header>
      <main>
        <div className="flex-1 flex flex-col items-center justify-items-start gap-16 min-h-0">
          <SortableRegion initial_items={INITIAL_ITEMS} />
        </div>
      </main>
    </div>
  );
}

And ๐Ÿ!

0
Subscribe to my newsletter

Read articles from Rich Burke directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Rich Burke
Rich Burke