Skip to content
Advertisement

How do you use state variables to make a nested drag and drop in React / Kanban board not working in React

Context: The main issue I am trying to solve is that stopping the propagation on an onDragStart event handler (via e.stopPropagation()) is disabling the drag-drop functionality all together. However, when I try to set a flag in the state, to stop drag-drop events to fire on parent elements; the flag does not work/ the state does not set in time or something.

Setup: A kanban style component, with draggable columns that contain draggable cards.

The columns are able to render correctly when you drag a column and reorder it. This is done by having an array of columns in a stateHook, and when a column’s “onDragStart” event is fired, setting the draggingIndex in the state. Then, when another column fires an “onDragOver” event, the array of columns is spliced, to remove the dragging column from it’s original position and insert it into the new order. This is working fine.

The issue arises when I try to drag cards, instead of the columns. When the “onDragStart” event fires on a card, I am setting a flag in a state hook. setDragCategory(“card”). The event listeners on the column elements are supposed to check to see if the “dragCategory === ‘card'”. And if it does, they are supposed to exit the function and not run any of the code.

The goal is that when you start dragging on a column, all of its event listeners fire, per normal. But if you start dragging on a card, the columns’ event listeners are essentially deactivated via exiting them before they do anything.

Even though the “onDragStart” handler on the card is running first (where the state is set to dragCategory === “card”, it is not preventing the columns’ event handlers from running. The column’s event handlers are then setting dragCategory === “column.” So, I a trying to drag a card, but instead, the columns are reordering.

I do not understand why the column’s event listeners are not exiting their handlers before this can happen.

Thank you for any pointers!

This code should work, if you paste it directly into the App.js file of a create-react-app project.

App.js:

import React, { useState } from "react";
import { v4 as uuid } from "uuid";

import "./App.css";

const data = {};
data.columns = [
  { name: "zero", cards: [{ text: "card one" }, { text: "card two" }] },
  { name: "one", cards: [{ text: "card three" }, { text: "card four" }] },
  { name: "two", cards: [{ text: "card five" }, { text: "card six" }] },
  { name: "three", cards: [{ text: "card seven" }, { text: "card eight" }] },
  { name: "four", cards: [{ text: "card nine" }, { text: "card ten" }] },
  { name: "five", cards: [{ text: "card eleven" }, { text: "card twelve" }] },
];

function App() {
  // when a card starts to drag, dragCategory is set to "card."  This is supposed to be a flag that will stop the columns' event listeners before they cause any changes in the state.  
  let [dragCategory, setDragCategory] = useState(null);

  // all of this is for reordering the columns. I have not gotten it to work well enough yet, to be able to write the state for reordering the cards:
  let [columns, setColumns] = useState(data.columns);
  let [draggingIndex, setDraggingIndex] = useState(null);
  let [targetIndex, setTargetIndex] = useState(null);

  return (
    <div className="App">
      <header>drag drop</header>
      <div className="kanban">
        {columns.map((column, i) => {
          return (
            <div
              data-index={i}
              onDragStart={(e) => {
                console.log("column drag start");
                // ERROR HERE: this function is supposed to exit here, if the drag event originated in a "card" component, but it is not exiting.
                if (dragCategory === "card") {
                  e.preventDefault();
                  return null;
                }
                setDragCategory("column");
                setDraggingIndex(i);
              }}
             // using onDragOver instead of onDragEnter because the onDragEnter handler causes the drop animation to return to the original place in the DOM instead of the current position that it should drop to.
              onDragOver={(e) => {
                if (dragCategory === "card") return null;
                // allows the drop event
                e.preventDefault();
                setTargetIndex(i);
                if (
                  dragCategory === "column" &&
                  targetIndex != null &&
                  targetIndex != draggingIndex
                ) {
                  let nextColumns = [...columns];
                  let currentItem = nextColumns[draggingIndex];
                  // remove current item
                  nextColumns.splice(draggingIndex, 1);
                  // insert item
                  nextColumns.splice(targetIndex, 0, currentItem);
                  setColumns(nextColumns);
                  setTargetIndex(i);
                  setDraggingIndex(i);
                }
              }}
              onDragEnter={(e) => {}}
              onDragEnd={(e) => {
                setDragCategory(null);
              }}
              onDrop={(e) => {}}
              className="column"
              key={uuid()}
              draggable={true}
            >
              {column.name}
              {column.cards.map((card) => {
                return (
                  <div
                    onDragStart={(e) => {
                      
                      setDragCategory("card");
                    }}
                    key={uuid()}
                    className="card"
                    draggable={true}
                  >
                    {card.text}
                  </div>
                );
              })}
            </div>
          );
        })}
      </div>
    </div>
  );
}

export default App;

And paste this starter css in the App.css file.

App.css

.kanban {
  display: flex;
  height: 90vh;
}

.column {
  border: solid orange 0.2rem;
  flex: 1;
}

.card {
  height: 5rem;
  width: 90%;
  margin: auto;
  margin-top: 2rem;
  border: solid gray 0.2rem;
}

Advertisement

Answer

You’re facing this issue because the state update is asynchronous.

https://reactjs.org/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous

In the column when your onDragStart event listener is checking if dragCategory === "card" the state change hasn’t occurred yet. That’s why the condition isn’t met.

To fix your issue you need to add event.stopPropagation() in the onDragStart of your card element. That way, your column onDragStart won’t fire at all when you drag a card.

Like so:

onDragStart={(e) => {
    e.stopPropagation();
    setDragCategory('card');
}}

Also if you have multiple state depending on each other, reducers are more appropriate.

https://reactjs.org/docs/hooks-reference.html#usereducer

I had a little bit of time so I created a codesandox using a reducer instead of state to fix the issue.

Improvements could be made but I didn’t have more time and I think it could get you on the right track.

Sometime onDragEnd doesn’t fire for the cards. I couldn’t figure out why.

That’s why sometimes the cards keep the dragged style with the dotted lines. When that happens it stops working correctly :/

https://codesandbox.io/s/zealous-kate-ezmqj

EDIT: Here is a library that might help you:

https://github.com/atlassian/react-beautiful-dnd

Here is an example of the library implemented:

https://react-beautiful-dnd.netlify.app/iframe.html?id=board–simple

User contributions licensed under: CC BY-SA
9 People found this is helpful
Advertisement