Sort It Out


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:
Update the React Router config to use server-side rendering.
Create a type to represent a sortable item.
Add a couple of utility functions for finding an item in the list.
Create a sortable card component.
Create a component that will as a container for the cards.
Update the Welcome page to use our sortable container and cards.
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 ๐!
Subscribe to my newsletter
Read articles from Rich Burke directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
