Remix Kanban Board
Seamless Workflow Management with Drag-and-Drop and Optimistic UI
A Step-by-Step Guide for Building an Interactive Kanban Board Application in Remix
Kanban boards are a popular tool used in project management and software development to visualize workflow and track the progress of tasks or work items through various stages. The Kanban methodology originated in lean manufacturing but has since been widely adopted across various industries due to its simplicity and effectiveness in promoting continuous improvement and efficient workflow.
In a Kanban board, work items are represented as cards that move horizontally across columns, each representing a distinct stage or status in the workflow. This visual representation allows teams to easily identify bottlenecks, prioritize tasks, and optimize their processes for maximum efficiency.
In this blog post, we'll explore how to develop a Kanban board using Remix, a modern React-based web framework that embraces server-side rendering and combines the best of React and server-side rendering.
We'll cover the following:
- Setting up a new Remix project and integrating Tailwind CSS for styling.
- Defining a task model and implementing server-side functionality to retrieve and update tasks.
- Building the Kanban board user interface with columns representing task statuses.
- Implementing drag-and-drop functionality to enable seamless task status updates.
- Incorporating a workflow with error handling to ensure data integrity and enforce business rules.
- Leveraging Remix's features to provide an optimistic user interface for a smooth and responsive experience.
By the end of this post, you'll have a solid understanding of how to create a fully functional Kanban board in Remix, complete with drag-and-drop functionality, workflow management, and a delightful user experience. Whether you're a project manager, developer, or anyone looking to streamline their processes, this tutorial will equip you with the knowledge to build a powerful and intuitive Kanban board application.
Content
- Setup
- Task Model and Server
- Kanban Board View
- Drag and Drop
- Status Change
- Workflow with error handling
- Optimistic UI
Setup
Lets start by creating a new Remix project using create-remix
:
npx create-remix@latest task-board
cd task-board
npx create-remix@latest task-board
cd task-board
Add Tailwind CSS and configure it to work with Remix. (see here for more details)
Task Model and Server
Lets add a model file to define our Task and a server file to get and update Task.
Create app\models\task.ts
and copy following.
export interface Task {
taskId: number;
taskName: string;
status: 'pending' | 'started' | 'completed' | 'holding';
}
// list of 20 in memory tasks
export const tasks: Task[] = Array.from({ length: 20 }, (_, i) => ({
taskId: i + 1,
taskName: `Task ${i + 1}`,
status: 'pending',
}));
export const taskStatusList: Task['status'][] = ['pending', 'started', 'completed', 'holding'];
export interface Task {
taskId: number;
taskName: string;
status: 'pending' | 'started' | 'completed' | 'holding';
}
// list of 20 in memory tasks
export const tasks: Task[] = Array.from({ length: 20 }, (_, i) => ({
taskId: i + 1,
taskName: `Task ${i + 1}`,
status: 'pending',
}));
export const taskStatusList: Task['status'][] = ['pending', 'started', 'completed', 'holding'];
Note that we are also generating and saving list of 20 task. This is just an example, in a real project, you will have your task in a database!
Add app\models\task.server.ts
file. It will be where you will be calling your backend/database. For this example, we will just be using our fixed task list!
import { Task, tasks } from './task';
// get list of task
export async function getTasks(): Promise<Task[]> {
return tasks;
}
// get task by id
export async function getTask(taskId: number): Promise<Task | undefined> {
return tasks.find((task) => task.taskId === taskId);
}
// update task status
export async function updateTaskStatus(taskId: number, status: Task['status']): Promise<Task | undefined> {
const task = tasks.find((task) => task.taskId === taskId);
if (!task) {
return;
}
task.status = status;
return task;
}
import { Task, tasks } from './task';
// get list of task
export async function getTasks(): Promise<Task[]> {
return tasks;
}
// get task by id
export async function getTask(taskId: number): Promise<Task | undefined> {
return tasks.find((task) => task.taskId === taskId);
}
// update task status
export async function updateTaskStatus(taskId: number, status: Task['status']): Promise<Task | undefined> {
const task = tasks.find((task) => task.taskId === taskId);
if (!task) {
return;
}
task.status = status;
return task;
}
We have added getTasks
, getTask
and updateTaskStatus
methods to the server that will be called from our routes. We will use these methods to get task and update its status.
Kanban Board View
Ok, time to add route and see our list of tasks on a Kanban board.
Add app\routes\task.tsx
file.
import { ActionFunctionArgs, json } from '@remix-run/node';
import { useFetcher, useFetchers, useLoaderData } from '@remix-run/react';
import { useState } from 'react';
import invariant from 'tiny-invariant';
import { Task, taskStatusList, taskWorkflow } from '~/models/task';
import { getTask, getTasks, updateTaskStatus } from '~/models/task.server';
export async function loader() {
const tasks: Task[] = await getTasks();
return json({ tasks });
}
export default function TaskListRoute() {
const { tasks } = useLoaderData<typeof loader>();
// create a board, board is made up of columns, each column = task status,
// each column has list of task filtered on task status for that column
return (
<div className="flex flex-row flex-nowrap m-4 gap-4">
{taskStatusList.map((status) => (
<Column key={status} status={status} tasks={tasks.filter((task) => task.status === status)} />
))}
</div>
);
}
function Column({ status, tasks }: { status: Task['status']; tasks: Task[] }) {
return (
<div
key={status}
className={` flex-shrink-0 flex flex-col overflow-hidden max-h-full w-80
border-slate-400 rounded-xl shadow-sm shadow-slate-400 bg-slate-100 `}
>
<div className="text-large font-semibold">{status}</div>
<div className="h-full overflow-auto">
{tasks.map((task) => (
<div key={task.taskId} className={'m-4 rounded-md border p-4'}>
<div className="text-large font-semibold">{task.taskName}</div>
</div>
))}
</div>
</div>
);
}
import { ActionFunctionArgs, json } from '@remix-run/node';
import { useFetcher, useFetchers, useLoaderData } from '@remix-run/react';
import { useState } from 'react';
import invariant from 'tiny-invariant';
import { Task, taskStatusList, taskWorkflow } from '~/models/task';
import { getTask, getTasks, updateTaskStatus } from '~/models/task.server';
export async function loader() {
const tasks: Task[] = await getTasks();
return json({ tasks });
}
export default function TaskListRoute() {
const { tasks } = useLoaderData<typeof loader>();
// create a board, board is made up of columns, each column = task status,
// each column has list of task filtered on task status for that column
return (
<div className="flex flex-row flex-nowrap m-4 gap-4">
{taskStatusList.map((status) => (
<Column key={status} status={status} tasks={tasks.filter((task) => task.status === status)} />
))}
</div>
);
}
function Column({ status, tasks }: { status: Task['status']; tasks: Task[] }) {
return (
<div
key={status}
className={` flex-shrink-0 flex flex-col overflow-hidden max-h-full w-80
border-slate-400 rounded-xl shadow-sm shadow-slate-400 bg-slate-100 `}
>
<div className="text-large font-semibold">{status}</div>
<div className="h-full overflow-auto">
{tasks.map((task) => (
<div key={task.taskId} className={'m-4 rounded-md border p-4'}>
<div className="text-large font-semibold">{task.taskName}</div>
</div>
))}
</div>
</div>
);
}
We have added loader
and TaskListRoute
to the route. We will use loader
to get list of tasks and TaskListRoute
to render tasks on board. Each task will be a column in the board based on status. Task Status = Kanban board Column.
Run the npm run init
and you should have a basic Kanban board with list of tasks. All in pending state. Lets add drag and drop functionality to let users change task status by moving task from one column to another.
Drag and Drop
Modify Column component in app\routes\task.tsx
to add drag and drop functionality to change task status as shown below
function Column({
status,
tasks,
}: {
status: Task['status']; // status from type Task
tasks: Task[];
}) {
const [acceptDrop, setAcceptDrop] = useState(false);
return (
<div
key={status}
className={
` flex-shrink-0 flex flex-col overflow-hidden max-h-full w-80
border-slate-400 rounded-xl shadow-sm shadow-slate-400 bg-slate-100 ` +
(acceptDrop ? `outline outline-2 outline-brand-red` : ``)
}
onDragOver={(event) => {
if (event.dataTransfer.types.includes('task')) {
event.preventDefault();
setAcceptDrop(true);
}
}}
onDragLeave={() => {
setAcceptDrop(false);
}}
onDrop={(event) => {
const task: Task = JSON.parse(event.dataTransfer.getData('task'));
if (task.status === status) {
setAcceptDrop(false);
return;
}
const taskToMove: Task = {
taskId: task.taskId,
taskName: task.taskName,
status,
};
setAcceptDrop(false);
}}
>
<div className="text-large font-semibold">{status}</div>
<div className="h-full overflow-auto">
{tasks.map((task) => (
<div
key={task.taskId}
draggable
onDragStart={(event) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('task', JSON.stringify(task));
}}
className={'m-4 rounded-md border p-4'}
>
<div className="text-large font-semibold">{task.taskName}</div>
</div>
))}
</div>
</div>
);
}
function Column({
status,
tasks,
}: {
status: Task['status']; // status from type Task
tasks: Task[];
}) {
const [acceptDrop, setAcceptDrop] = useState(false);
return (
<div
key={status}
className={
` flex-shrink-0 flex flex-col overflow-hidden max-h-full w-80
border-slate-400 rounded-xl shadow-sm shadow-slate-400 bg-slate-100 ` +
(acceptDrop ? `outline outline-2 outline-brand-red` : ``)
}
onDragOver={(event) => {
if (event.dataTransfer.types.includes('task')) {
event.preventDefault();
setAcceptDrop(true);
}
}}
onDragLeave={() => {
setAcceptDrop(false);
}}
onDrop={(event) => {
const task: Task = JSON.parse(event.dataTransfer.getData('task'));
if (task.status === status) {
setAcceptDrop(false);
return;
}
const taskToMove: Task = {
taskId: task.taskId,
taskName: task.taskName,
status,
};
setAcceptDrop(false);
}}
>
<div className="text-large font-semibold">{status}</div>
<div className="h-full overflow-auto">
{tasks.map((task) => (
<div
key={task.taskId}
draggable
onDragStart={(event) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('task', JSON.stringify(task));
}}
className={'m-4 rounded-md border p-4'}
>
<div className="text-large font-semibold">{task.taskName}</div>
</div>
))}
</div>
</div>
);
}
In the sprit of Remix, we will use the "platform" for drag and drop. In real world apps, you might want to consider using drag and drop library like react-dnd or dnd-kit.
We first make the card draggable by adding a draggable
attribute and when user starts dragging, we store task in dataTransfer using setData
in onDragStart
. We let user drag card from one column to another. For this, we are defining onDragOver
and onDragLeave
to show outline when user is hovering over the column. We also use onDrop
to retrieve the task card that was dropped.
If you run the project, you will be able to drag card over, column should be outlined and card should be dropped. But it won't stay there as we are not updating status of task yet. Let do that next.
Status Change
When user drop card from one column to another, we will update status of task. To do that first add an action
to route to update status. In our case, we will update status of task by calling updateTaskStatus
method.
Add action
to app\routes\task.tsx
as well as update Column
as shown below:
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const method = formData.get('method');
if (method !== 'put') {
return json({
success: false,
error: 'Unknown method',
});
}
const taskId = +(formData.get('taskId')?.toString() || 0);
const status = formData.get('status')?.toString();
if (!taskId || !status) {
return json({
success: false,
error: 'Missing data',
});
}
if (!taskStatusList.includes(status as Task['status'])) {
return json({
success: false,
error: 'Invalid status',
});
}
//
const existingTask = await getTask(taskId);
if (!existingTask) {
return json({
success: false,
error: 'Task not found',
});
}
// update task
await updateTaskStatus(taskId, status as Task['status']);
return json({ success: true });
}
function Column({
status,
tasks,
}: {
status: Task['status']; // status from type Task
tasks: Task[];
}) {
const [acceptDrop, setAcceptDrop] = useState(false);
const fetcher = useFetcher();
const submit = fetcher.submit;
return (
<div
key={status}
className={
` flex-shrink-0 flex flex-col overflow-hidden max-h-full w-80
border-slate-400 rounded-xl shadow-sm shadow-slate-400 bg-slate-100 ` +
(acceptDrop ? `outline outline-2 outline-brand-red` : ``)
}
onDragOver={(event) => {
if (event.dataTransfer.types.includes('task')) {
event.preventDefault();
setAcceptDrop(true);
}
}}
onDragLeave={() => {
setAcceptDrop(false);
}}
onDrop={(event) => {
const task: Task = JSON.parse(event.dataTransfer.getData('task'));
if (task.status === status) {
setAcceptDrop(false);
return;
}
const taskToMove: Task = {
taskId: task.taskId,
taskName: task.taskName,
status,
};
submit(
{ ...taskToMove, method: 'put' },
{
method: 'put',
navigate: false,
},
);
setAcceptDrop(false);
}}
>
<div className="text-large font-semibold">{status}</div>
<div className="h-full overflow-auto">
{tasks.map((task) => (
<div
key={task.taskId}
draggable
onDragStart={(event) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('task', JSON.stringify(task));
}}
className={
'm-4 rounded-md border p-4' +
((task as Task & { pending?: boolean })?.pending ? ` border-dashed border-slate-300` : ``)
}
>
<div className="text-large font-semibold">{task.taskName}</div>
</div>
))}
</div>
</div>
);
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const method = formData.get('method');
if (method !== 'put') {
return json({
success: false,
error: 'Unknown method',
});
}
const taskId = +(formData.get('taskId')?.toString() || 0);
const status = formData.get('status')?.toString();
if (!taskId || !status) {
return json({
success: false,
error: 'Missing data',
});
}
if (!taskStatusList.includes(status as Task['status'])) {
return json({
success: false,
error: 'Invalid status',
});
}
//
const existingTask = await getTask(taskId);
if (!existingTask) {
return json({
success: false,
error: 'Task not found',
});
}
// update task
await updateTaskStatus(taskId, status as Task['status']);
return json({ success: true });
}
function Column({
status,
tasks,
}: {
status: Task['status']; // status from type Task
tasks: Task[];
}) {
const [acceptDrop, setAcceptDrop] = useState(false);
const fetcher = useFetcher();
const submit = fetcher.submit;
return (
<div
key={status}
className={
` flex-shrink-0 flex flex-col overflow-hidden max-h-full w-80
border-slate-400 rounded-xl shadow-sm shadow-slate-400 bg-slate-100 ` +
(acceptDrop ? `outline outline-2 outline-brand-red` : ``)
}
onDragOver={(event) => {
if (event.dataTransfer.types.includes('task')) {
event.preventDefault();
setAcceptDrop(true);
}
}}
onDragLeave={() => {
setAcceptDrop(false);
}}
onDrop={(event) => {
const task: Task = JSON.parse(event.dataTransfer.getData('task'));
if (task.status === status) {
setAcceptDrop(false);
return;
}
const taskToMove: Task = {
taskId: task.taskId,
taskName: task.taskName,
status,
};
submit(
{ ...taskToMove, method: 'put' },
{
method: 'put',
navigate: false,
},
);
setAcceptDrop(false);
}}
>
<div className="text-large font-semibold">{status}</div>
<div className="h-full overflow-auto">
{tasks.map((task) => (
<div
key={task.taskId}
draggable
onDragStart={(event) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('task', JSON.stringify(task));
}}
className={
'm-4 rounded-md border p-4' +
((task as Task & { pending?: boolean })?.pending ? ` border-dashed border-slate-300` : ``)
}
>
<div className="text-large font-semibold">{task.taskName}</div>
</div>
))}
</div>
</div>
);
}
We have added action
to update task status. We first check for data integrity and then call updateTaskStatus
method. Column
has been updated to use useFetcher
hook to submit data without making a transition. onDrop
uses fetcher.submit method to update task status. (We are missing error handling, we will add that later)
Run the project and drop card from one column to another. It should update status.
Workflow with error handling
Now that we have the basic Kanban board working, lets add a workflow as well as error handling. For this example, we will use a simple process flow, where user can move task from pending to started to completed. If task is completed it can't be moved back to any other state. Once task has started, it can't be moved to pending.
While there are various ways to model process flow, we will just define a simple object to codify our process flow rules.
Add taskWorkflow
to app\models\task.ts
as shown below
export const taskWorkflow = {
pending: ['started'],
started: ['completed', 'holding'],
holding: ['started'],
completed: [],
};
export const taskWorkflow = {
pending: ['started'],
started: ['completed', 'holding'],
holding: ['started'],
completed: [],
};
And modify action
and TaskListRoute
in app\routes\task.tsx
as follows:
export async function action({ request }: ActionFunctionArgs) {
// artificial delay
await new Promise((resolve) => setTimeout(resolve, 1000));
const formData = await request.formData();
const method = formData.get('method');
if (method === 'put') {
const taskId = +(formData.get('taskId')?.toString() || 0);
const status = formData.get('status')?.toString();
if (!taskId || !status) {
return json({
success: false,
error: 'Missing data',
});
}
if (!taskStatusList.includes(status as Task['status'])) {
return json({
success: false,
error: 'Invalid status',
});
}
//
const existingTask = await getTask(taskId);
if (!existingTask) {
return json({
success: false,
error: 'Task not found',
});
}
// check for rules to validate task transition
const currentStatus = existingTask.status;
const validTransition = (taskWorkflow[currentStatus] as Task['status'][]).includes(status as Task['status']);
if (!validTransition) {
return json({
success: false,
error: `Invalid transition, can not move task from ${currentStatus} to ${status}`,
});
}
// update task
await updateTaskStatus(taskId, status as Task['status']);
return json({ success: true });
}
}
export default function TaskListRoute() {
const { tasks } = useLoaderData<typeof loader>();
const fetchers = useFetchers();
// create a board, board is made up of columns, each column = task status,
// each column has list of task filtered on task status for that column
const error = fetchers.filter((fetcher) => fetcher.data?.error)?.[0]?.data?.error;
return (
<div>
{error ? <div className="text-red-500 p-4 m-4">{error}</div> : null}
<div className="flex flex-row flex-nowrap m-4 gap-4">
{taskStatusList.map((status) => (
<Column key={status} status={status} tasks={tasks.filter((task) => task.status === status)} />
))}
</div>
</div>
);
}
export async function action({ request }: ActionFunctionArgs) {
// artificial delay
await new Promise((resolve) => setTimeout(resolve, 1000));
const formData = await request.formData();
const method = formData.get('method');
if (method === 'put') {
const taskId = +(formData.get('taskId')?.toString() || 0);
const status = formData.get('status')?.toString();
if (!taskId || !status) {
return json({
success: false,
error: 'Missing data',
});
}
if (!taskStatusList.includes(status as Task['status'])) {
return json({
success: false,
error: 'Invalid status',
});
}
//
const existingTask = await getTask(taskId);
if (!existingTask) {
return json({
success: false,
error: 'Task not found',
});
}
// check for rules to validate task transition
const currentStatus = existingTask.status;
const validTransition = (taskWorkflow[currentStatus] as Task['status'][]).includes(status as Task['status']);
if (!validTransition) {
return json({
success: false,
error: `Invalid transition, can not move task from ${currentStatus} to ${status}`,
});
}
// update task
await updateTaskStatus(taskId, status as Task['status']);
return json({ success: true });
}
}
export default function TaskListRoute() {
const { tasks } = useLoaderData<typeof loader>();
const fetchers = useFetchers();
// create a board, board is made up of columns, each column = task status,
// each column has list of task filtered on task status for that column
const error = fetchers.filter((fetcher) => fetcher.data?.error)?.[0]?.data?.error;
return (
<div>
{error ? <div className="text-red-500 p-4 m-4">{error}</div> : null}
<div className="flex flex-row flex-nowrap m-4 gap-4">
{taskStatusList.map((status) => (
<Column key={status} status={status} tasks={tasks.filter((task) => task.status === status)} />
))}
</div>
</div>
);
}
In action
we added check for task status change validity. We first get current status and then check if new status is in valid transition given current status. If not, we return an error.
We also modified TaskListRoute
to get error from fetcher.data
and to display if error is present.
Normally you use useActionData hook to get results of action, but here we are submitting data using a fetcher and hence we look for data returned by the action on fetcher via fetcher.data property.
Run the project. Move task from pending to started. It should work. Try to move it back to pending. It should not work.
Optimistic UI
One of the key features of Remix is its ability to provide an optimistic UI experience out of the box. Optimistic UI is a technique that allows you to update the user interface immediately after a user action, such as submitting a form or making a request, without waiting for the server response. This creates a smooth experience for the user, as they can see the expected changes right away, rather than waiting for the server to process the request and send back a response.
In the context of our Kanban board, we can leverage the optimistic UI approach to update the task status instantly when a user drags and drops a task card from one column to another. This way, the user perceives the status change as immediate, even before the server has processed the update request.
Remix makes implementing optimistic UI a breeze by providing access to the useFetchers hook and the _fetcher.formData _ property. The useFetchers hook returns an array of fetchers, which are instances of the Fetcher component used to submit data without causing a full page transition.
In our implementation, we use the useFetchers
hook to get the list of pending fetchers (i.e., fetchers that have been submitted but haven't received a response yet). We then filter this list to find the fetchers that are submitting a put request, which is our action to update the task status.
Entire app\routes\task.tsx
with optimistic UI is shown below:
import { ActionFunctionArgs, json } from '@remix-run/node';
import { useFetcher, useFetchers, useLoaderData } from '@remix-run/react';
import { useState } from 'react';
import invariant from 'tiny-invariant';
import { Task, taskStatusList, taskWorkflow } from '~/models/task';
import { getTask, getTasks, updateTaskStatus } from '~/models/task.server';
export async function loader() {
const tasks: Task[] = await getTasks();
return json({ tasks });
}
export async function action({ request }: ActionFunctionArgs) {
// artificial delay, uncomment to see optimistic UI in action
// await new Promise((resolve) => setTimeout(resolve, 1000));
const formData = await request.formData();
const method = formData.get('method');
if (method === 'put') {
const taskId = +(formData.get('taskId')?.toString() || 0);
const status = formData.get('status')?.toString();
if (!taskId || !status) {
return json({
success: false,
error: 'Missing data',
});
}
if (!taskStatusList.includes(status as Task['status'])) {
return json({
success: false,
error: 'Invalid status',
});
}
//
const existingTask = await getTask(taskId);
if (!existingTask) {
return json({
success: false,
error: 'Task not found',
});
}
// check for rules to validate task transition
const currentStatus = existingTask.status;
const validTransition = (taskWorkflow[currentStatus] as Task['status'][]).includes(status as Task['status']);
if (!validTransition) {
return json({
success: false,
error: `Invalid transition, can not move task from ${currentStatus} to ${status}`,
});
}
// update task
await updateTaskStatus(taskId, status as Task['status']);
return json({ success: true });
}
}
export default function TaskListRoute() {
const { tasks } = useLoaderData<typeof loader>();
const fetchers = useFetchers();
const pendingTasks = usePendingTasks();
// create a board, board is made up of columns, each column = task status,
// each column has list of task filtered on task status for that column
const error = fetchers.filter((fetcher) => fetcher.data?.error)?.[0]?.data?.error;
for (const pendingTask of pendingTasks) {
const task = tasks.find((task) => task.taskId === pendingTask.taskId);
if (task) {
task.status = pendingTask.status;
(task as Task & { pending?: boolean }).pending = true;
}
}
return (
<div>
{error ? <div className="text-red-500 p-4 m-4">{error}</div> : null}
<div className="flex flex-row flex-nowrap m-4 gap-4">
{taskStatusList.map((status) => (
<Column key={status} status={status} tasks={tasks.filter((task) => task.status === status)} />
))}
</div>
</div>
);
}
function Column({ status, tasks }: { status: Task['status']; tasks: Task[] }) {
const [acceptDrop, setAcceptDrop] = useState(false);
const fetcher = useFetcher();
const submit = fetcher.submit;
return (
<div
key={status}
className={
`flex-shrink-0 flex flex-col overflow-hidden max-h-full w-80
border-slate-400 rounded-xl shadow-sm shadow-slate-400 bg-slate-100` +
(acceptDrop ? `outline outline-2 outline-brand-red` : ``)
}
onDragOver={(event) => {
if (event.dataTransfer.types.includes('task')) {
event.preventDefault();
setAcceptDrop(true);
}
}}
onDragLeave={() => {
setAcceptDrop(false);
}}
onDrop={(event) => {
const task: Task = JSON.parse(event.dataTransfer.getData('task'));
if (task.status === status) {
setAcceptDrop(false);
return;
}
const taskToMove: Task = {
taskId: task.taskId,
taskName: task.taskName,
status,
};
submit(
{ ...taskToMove, method: 'put' },
{
method: 'put',
navigate: false,
},
);
setAcceptDrop(false);
}}
>
<div className="text-large font-semibold">{status}</div>
<div className="h-full overflow-auto">
{tasks.map((task) => (
<div
key={task.taskId}
draggable
onDragStart={(event) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('task', JSON.stringify(task));
}}
className={
'm-4 rounded-md border p-4' +
((task as Task & { pending?: boolean })?.pending ? ` border-dashed border-slate-300` : ``)
}
>
<div className="text-large font-semibold">{task.taskName}</div>
</div>
))}
</div>
</div>
);
}
function usePendingTasks() {
type PendingItem = ReturnType<typeof useFetchers>[number] & {
formData: FormData;
};
return useFetchers()
.filter((fetcher): fetcher is PendingItem => {
if (!fetcher.formData) return false;
const intent = fetcher.formData.get('method');
return intent === 'put';
})
.map((fetcher) => {
const taskId = Number(fetcher.formData.get('taskId'));
const taskName = String(fetcher.formData.get('taskName'));
const status = String(fetcher.formData.get('status')) as Task['status'];
const item: Task = { taskId, taskName, status };
return item;
});
}
import { ActionFunctionArgs, json } from '@remix-run/node';
import { useFetcher, useFetchers, useLoaderData } from '@remix-run/react';
import { useState } from 'react';
import invariant from 'tiny-invariant';
import { Task, taskStatusList, taskWorkflow } from '~/models/task';
import { getTask, getTasks, updateTaskStatus } from '~/models/task.server';
export async function loader() {
const tasks: Task[] = await getTasks();
return json({ tasks });
}
export async function action({ request }: ActionFunctionArgs) {
// artificial delay, uncomment to see optimistic UI in action
// await new Promise((resolve) => setTimeout(resolve, 1000));
const formData = await request.formData();
const method = formData.get('method');
if (method === 'put') {
const taskId = +(formData.get('taskId')?.toString() || 0);
const status = formData.get('status')?.toString();
if (!taskId || !status) {
return json({
success: false,
error: 'Missing data',
});
}
if (!taskStatusList.includes(status as Task['status'])) {
return json({
success: false,
error: 'Invalid status',
});
}
//
const existingTask = await getTask(taskId);
if (!existingTask) {
return json({
success: false,
error: 'Task not found',
});
}
// check for rules to validate task transition
const currentStatus = existingTask.status;
const validTransition = (taskWorkflow[currentStatus] as Task['status'][]).includes(status as Task['status']);
if (!validTransition) {
return json({
success: false,
error: `Invalid transition, can not move task from ${currentStatus} to ${status}`,
});
}
// update task
await updateTaskStatus(taskId, status as Task['status']);
return json({ success: true });
}
}
export default function TaskListRoute() {
const { tasks } = useLoaderData<typeof loader>();
const fetchers = useFetchers();
const pendingTasks = usePendingTasks();
// create a board, board is made up of columns, each column = task status,
// each column has list of task filtered on task status for that column
const error = fetchers.filter((fetcher) => fetcher.data?.error)?.[0]?.data?.error;
for (const pendingTask of pendingTasks) {
const task = tasks.find((task) => task.taskId === pendingTask.taskId);
if (task) {
task.status = pendingTask.status;
(task as Task & { pending?: boolean }).pending = true;
}
}
return (
<div>
{error ? <div className="text-red-500 p-4 m-4">{error}</div> : null}
<div className="flex flex-row flex-nowrap m-4 gap-4">
{taskStatusList.map((status) => (
<Column key={status} status={status} tasks={tasks.filter((task) => task.status === status)} />
))}
</div>
</div>
);
}
function Column({ status, tasks }: { status: Task['status']; tasks: Task[] }) {
const [acceptDrop, setAcceptDrop] = useState(false);
const fetcher = useFetcher();
const submit = fetcher.submit;
return (
<div
key={status}
className={
`flex-shrink-0 flex flex-col overflow-hidden max-h-full w-80
border-slate-400 rounded-xl shadow-sm shadow-slate-400 bg-slate-100` +
(acceptDrop ? `outline outline-2 outline-brand-red` : ``)
}
onDragOver={(event) => {
if (event.dataTransfer.types.includes('task')) {
event.preventDefault();
setAcceptDrop(true);
}
}}
onDragLeave={() => {
setAcceptDrop(false);
}}
onDrop={(event) => {
const task: Task = JSON.parse(event.dataTransfer.getData('task'));
if (task.status === status) {
setAcceptDrop(false);
return;
}
const taskToMove: Task = {
taskId: task.taskId,
taskName: task.taskName,
status,
};
submit(
{ ...taskToMove, method: 'put' },
{
method: 'put',
navigate: false,
},
);
setAcceptDrop(false);
}}
>
<div className="text-large font-semibold">{status}</div>
<div className="h-full overflow-auto">
{tasks.map((task) => (
<div
key={task.taskId}
draggable
onDragStart={(event) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('task', JSON.stringify(task));
}}
className={
'm-4 rounded-md border p-4' +
((task as Task & { pending?: boolean })?.pending ? ` border-dashed border-slate-300` : ``)
}
>
<div className="text-large font-semibold">{task.taskName}</div>
</div>
))}
</div>
</div>
);
}
function usePendingTasks() {
type PendingItem = ReturnType<typeof useFetchers>[number] & {
formData: FormData;
};
return useFetchers()
.filter((fetcher): fetcher is PendingItem => {
if (!fetcher.formData) return false;
const intent = fetcher.formData.get('method');
return intent === 'put';
})
.map((fetcher) => {
const taskId = Number(fetcher.formData.get('taskId'));
const taskName = String(fetcher.formData.get('taskName'));
const status = String(fetcher.formData.get('status')) as Task['status'];
const item: Task = { taskId, taskName, status };
return item;
});
}
Here's the usePendingTasks
hook that we've added to our code:
function usePendingTasks() {
type PendingItem = ReturnType<typeof useFetchers>[number] & {
formData: FormData;
};
return useFetchers()
.filter((fetcher): fetcher is PendingItem => {
if (!fetcher.formData) return false;
const intent = fetcher.formData.get('method');
return intent === 'put';
})
.map((fetcher) => {
const taskId = Number(fetcher.formData.get('taskId'));
const taskName = String(fetcher.formData.get('taskName'));
const status = String(fetcher.formData.get('status')) as Task['status'];
const item: Task = { taskId, taskName, status };
return item;
});
}
function usePendingTasks() {
type PendingItem = ReturnType<typeof useFetchers>[number] & {
formData: FormData;
};
return useFetchers()
.filter((fetcher): fetcher is PendingItem => {
if (!fetcher.formData) return false;
const intent = fetcher.formData.get('method');
return intent === 'put';
})
.map((fetcher) => {
const taskId = Number(fetcher.formData.get('taskId'));
const taskName = String(fetcher.formData.get('taskName'));
const status = String(fetcher.formData.get('status')) as Task['status'];
const item: Task = { taskId, taskName, status };
return item;
});
}
This hook filters the fetchers array to find the ones that are submitting a put request (i.e., updating the task status). It then maps over these fetchers and extracts the task data from the fetcher.formData property, which contains the form data submitted with the request.
We then use the usePendingTasks hook in our TaskListRoute component to update the task status immediately after the user drops a task card in a new column:
const pendingTasks = usePendingTasks();
for (const pendingTask of pendingTasks) {
const task = tasks.find((task) => task.taskId === pendingTask.taskId);
if (task) {
task.status = pendingTask.status;
(task as Task & { pending?: boolean }).pending = true;
}
}
const pendingTasks = usePendingTasks();
for (const pendingTask of pendingTasks) {
const task = tasks.find((task) => task.taskId === pendingTask.taskId);
if (task) {
task.status = pendingTask.status;
(task as Task & { pending?: boolean }).pending = true;
}
}
By iterating over the pending tasks and updating the status property of the corresponding task object, we can immediately reflect the status change in the UI. To better visualize the optimistic UI part, we are setting a pending property on the task object, which we use to conditionally apply a dashed border style to the task card, indicating that the status change is pending server confirmation.
If the server responds with an error (e.g., due to a workflow violation or data inconsistency), Remix will automatically revert the UI to its previous state by re-rendering the route with the updated data from the server. This ensures that the user always sees the correct state of the application, even in the case of errors or unsuccessful operations.
By combining the power of Remix's data handling capabilities with the optimistic UI approach, we can provide a smooth and intuitive user experience for our Kanban board application, making it feel snappy and easy to use.
Next Steps
Hopefully, you now have a good idea of how to develop a Kanban board with a workflow and optimistic UI in Remix. Through this tutorial, we explored various aspects of building a Kanban board application, including setting up the project, defining the data model, implementing the user interface, incorporating drag-and-drop functionality, handling workflow rules, and leveraging Remix's unique features to provide a delightful user experience.
Some key takeaways from this tutorial include:
-
Remix's Server-Side Rendering:
By embracing server-side rendering, Remix allows you to build highly interactive and responsive web applications without sacrificing performance or search engine optimization (SEO).
-
Optimistic UI:
Remix's built-in support for optimistic UI makes it easy to create smooth and responsive user experiences, as demonstrated in our Kanban board implementation with instant task status updates.
-
Data Flow and Error Handling:
Remix's opinionated approach to data flow and error handling simplifies the management of application state and ensures that errors are handled gracefully, maintaining data integrity and providing a consistent user experience.
-
Integrated Routing and Data Loading:
Remix's integrated routing and data loading mechanisms streamline the development process, allowing you to co-locate your routes, UI components, and data-fetching logic within the same file structure.
By leveraging Remix's powerful features, you can build robust and scalable web applications while maintaining a high level of developer productivity and user satisfaction. The combination of server-side rendering, optimistic UI, and efficient data handling makes Remix an excellent choice for developing Kanban boards and other interactive applications that require real-time updates and a seamless user experience.
Find the source code for the ready-to-run project on GitHub.
If you're interested in further accelerating your Remix development workflow, consider exploring RemixFast, a no-code app builder that can generate Kanban boards and other applications with complete backend integration, 10 times faster than traditional coding methods.
Remix Modal Route
Learn how to develop and display route in a Modal.