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

Manish Dalal - Posted on 18 Mar 2024. - 8 min read

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:

  1. Setting up a new Remix project and integrating Tailwind CSS for styling.
  2. Defining a task model and implementing server-side functionality to retrieve and update tasks.
  3. Building the Kanban board user interface with columns representing task statuses.
  4. Implementing drag-and-drop functionality to enable seamless task status updates.
  5. Incorporating a workflow with error handling to ensure data integrity and enforce business rules.
  6. 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.

image

Content

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:

  1. 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).

  2. 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.

  3. 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.

  4. 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.

11 Mar 2024

Remix Modal Route

Learn how to develop and display route in a Modal.

By using RemixFast, you agree to our Cookie Policy.