React Drag And Drop - Kanban board

·

8 min read

Today we'll build this simple Kanban Board using NextJS and React-DND.

This is the final outcome.

Installation

Create a new Next ( or react ) app

npx create-next-app nextdnd

I'm using Tailwind for the styling! so whenever you see {...style} it's just a bunch of tailwind classes. Also it's important to choose TS as a template.

Install React Drag & Drop Package

npm install react-dnd react-dnd-html5-backend

Folder Structure

  • useHandleTicketsState: where we'll manage our state

  • domain folder: our data and our types

The rest is the usual NextJS - React folders where we put our pages and our components.

For the components we have only 2; Column & Ticket

Column

Each column will have a title ( State ) and a List to show the tickets with this state.

// Our columns
export const canbanColumns = ["Backlog","Todo", "In Progress", "Testing", "Done"];

Ticket

It just has a title and a state

Types

export type State = 'Todo' | 'In Progress' | 'Testing' | 'Done' | 'Backlog';

export type Ticket = {
  title:string;
  state:State;
  id:string;
}

Philosophy Behind React-DnD

React-DnD makes drag & drop very easy. It has 2 main hooks we can use

  • useDrop

  • useDrag

We use the useDrop hook wherever we want to drop our components and use useDrag hook in the component we want to drag.

In this example, we want to drag tickets! So you guessed, we use the useDrag hook in the Ticket component and we use the useDrop hook in the Column component.

We can easily exchange data between the dragged component and where we want to drop.

Ticket - Drag

export function TicketCard({ticket}:{ticket:Ticket}){
    const [{ isDragging }, drag] = useDrag<
      Ticket,
      void,
      { isDragging: boolean;}
    >(() => ({
      // "type" is required. It is used by the "accept" specification of drop targets.
      type: 'ticket',
      // The collect function utilizes a "monitor" instance (see the Overview for what this is)
      // to pull important pieces of state from the DnD system.
      item: ticket,
      collect: monitor => ({
        // We use it to hide the ticket from the current Column when
        // start dragging.
        isDragging: monitor.isDragging(),
      }),
    }));
  return <div ref={drag} className={clsx('...style',{
    'hidden':isDragging
  })}>
        <p>{isDragging ? 'dragging' : ticket.title}</p>
        <span className='...style'>{ticket.state}</span>
       </div>
  }

As you can see, the useDrag hook is generic, the first Type represents the data we'll exchange between the Dragged component and where we will drop. In our case, it's the Ticket type.

The second type is not important in our case, it's just the return type of the drop method we use in the useDrop hook.

The last type is the return type of the collect method in both useDrop and useDrag. This method collects things we might need like whether the TicketCard is being dragged or is it dropped etc. ( All collected from the monitor instance )

The type property is very important, it's required for both useDrop and useDrag (accepts in the useDrag hook) and it's basically just a string to link the Draggable component and where it can be dropped. So if I put ticket in the useDrag of Ticket and I put also ticket in the useDrop in the Column component, this means that we can drop the Ticket on the Column.

The item is also very important, this is where we put the data we need to exchange between the dragged and where we drop our Ticket! You can consider it as a prop flying around while you're dragging and finally it gets to where you'll drop your Component through the drop method we'll see in a bit!

Finally, the drag is a ref we should pass it to our Component so that ReactDND can reference our element and track its position etc.

Column - Drop

export function CanbanColumn({ columnState,tickets,children,moveTicket }: PropsWithChildren<{ columnState: State, tickets:Ticket[], moveTicket:(ticket:Ticket,columnState:State)=>void}>) {
    const [{ isOver, canDrop }, drop] = useDrop<
    Ticket,
    void,
    { canDrop: boolean; isOver: boolean }
  >(() => ({
    accept: 'ticket',
    drop: ticket => {
        // do our logic here.
        moveTicket(ticket,columnState);
    },
    collect: monitor => ({
      // isOver is true when we the Ticket is ready to be dropped
      isOver: monitor.isOver(),
      // canDrop is true for all Columns that has the same 
      // `accept` property as the `type` property in the useDrag.
      // in this example all whenever we start dragging
      // All columns have `canDrop` true because the accept here is         // 'ticket' which is equal to the type in the useDrag.
      canDrop: monitor.canDrop(),
    }),
  }));
    return (
      <div ref={drop} className={clsx("...style",{
        "bg-slate-300":isOver,
        "ring ring-blue-200":canDrop
      })}>
        <p className='...style'>{columnState}</p>
        <div className={clsx("rounded-xl flex flex-col gap-2 p-2")}>
             {tickets.map(ticket=>
             <TicketCard 
                key={ticket.id} 
                ticket={ticket}/>)}
        </div>
      </div>
    );
  }

In the useDrop hook we have mostly the same property, except for the accept property which should have the same string as the type in the useDrag so that we can drop our TicketCard in the Column.

We also have the drop method which takes as a parameter the type we specified here Ticket. As we said earlier, we pass our data in the item property in useDrag it will fly around with the dragged Component and finally gets into this method! Here we can do whatever we want with this data.

The final piece of the puzzle would be our state. How we move Tickets between columns. For this, we'll check our useHandleTicketsState hook.

State Management

Nothing fancy here, I'm not using redux or any state management library! Instead, I'm just using plain React to manage the state with the help of useReducer

In This article I'm assuming you know the basics of useReducer hook! If not you can check it real quick, it's simple and very similar to redux actions, reducers logic.

/*** 
We're using the discriminated union. So if the type is "move-tickets" TS will infer the payload as {newState:State...} and if the type is "reset" we won't have a payload.
*/
type Action =
  | { type: "move-ticket"; payload: { newState: State; ticket: Ticket } }
  | { type: "reset" };
const initialState = {
  tickets: {
    Backlog: [...tickets],
    Todo: [],
    "In Progress": [],
    Testing: [],
    Done: [],
  },
};
type TicketsState = {
  tickets: Record<State, Ticket[]>;
};
const reducer = (state: TicketsState, action: Action): TicketsState => {
  const { type } = action;
  switch (type) {
    case "move-ticket": {
      const { payload } = action;
      // If we try to drop the ticket on the same column this means 
      // we won't change the state so we just return it from the          //beginning
      return payload.newState === payload.ticket.state
        ? state
        : {
            ...state,
            tickets: {
              ...state.tickets,
              // we remove the ticket from the current column
              [payload.ticket.state]: state.tickets[
                payload.ticket.state
              ].filter((ticket) => ticket.id !== payload.ticket.id),
              // append it to the new column
              [payload.newState]: [
                ...state.tickets[payload.newState],
                { ...payload.ticket, state: payload.newState },
              ],
            },
          };
    }
    case "reset":
      // Here we just return the initial state.
      return {
        ...initialState,
      };
  }
};
export const useHandleTicketsState = () => {
  const [state, dispatch] = useReducer(reducer, { ...initialState });

  const moveTicket = useCallback(
    (ticket: Ticket, newState: State) =>
      dispatch({ type: "move-ticket", payload: { ticket, newState } }),
    []
  );
  const reset = useCallback(() => dispatch({ type: "reset" }), []);
  return {
    state,
    reset,
    moveTicket,
  };
};

In a nutshell, our state is a simple object with tickets property. tickets is just a Record or an object with key:State and value is an array of tickets

This will make accessing tickets by state is really simple and in constant time.

We check the type of our action, if it's move-ticket we remove the ticket from the current column and append it to the new one. If the action is reset we just return the initial state where all tickets are in the backlog.

Our Page

export default function Home() {
  const {state,reset,moveTicket}=useHandleTicketsState();
  return (
    <DndProvider backend={HTML5Backend}>
    <main className={`p-2 flex min-h-screen bg-white gap-2 ${inter.className}`}>
      <button
     onClick={reset} 
      className="absolute bottom-4 left-4 rounded-lg bg-slate-950 text-white px-4 py-2">Reset</button>
            <div className="flex w-full gap-4">
        {canbanColumns.map((columnState) => (
          <CanbanColumn 
             tickets={state.tickets[columnState as State]} 
             key={columnState} 
             moveTicket={moveTicket} 
             columnState={columnState as State} />
        ))}
      </div>
    </main>
    </DndProvider>
  );
}

This is our Page! It's important to wrap our Components with the DndProvider and pass the backend to it. In this case, we installed the HTML5Backend along with react-dnd

Here we have a simple button to reset the state and a CanbanColumn component which takes the state, moveTicket method ( which is just a dispatch with action move-ticket and we pass as children our tickets of the corresponding state. ( This is one of the reasons why we used a Record or an Object so we don't have to use Array.filter , we just access our tickets by ColumnState directly ).

And That's all! Hope you enjoyed this article and learned something new!

React DND has other cool features like custom layer if you want to show a custom component while you're dragging and much more! We just covered the basics and the most needed features in this Example.

Conclusion

In This article, we learned how to use react-dnd to implement a simple drag-and-drop feature.

react-dnd provides mainly 2 hooks, one for the dragged component and the other where we'll drop it!

useDrop and useDrag should only be used in the components that are located under the DndProvider.

useReducer most of the time can replace many libraries if we learn how to use it and how to keep our state closest to where it is used!

GITHUBREPO: https://github.com/hedi-ghodhbane/React-Drag-Drop