Build a reorderable list in react

h8moss

h8moss

Posted on January 8, 2023

Build a reorderable list in react

What are we building?

Hello all! In this tutorial I want to take you through the process of building a reorderable list in react. The list we are gonna be building is probably far too simple for usage in real-world apps, but it should serve as a good starting point.

Here is a demo of what we are gonna be building:

Reorderable list demo

Here is a code sandbox demo aswell for you to play around in:

(In the demo, we are reordering the lyrics of the song tiptoe through the true bits by Los Campesinos! it's a great song, so go give it a listen!)

Writing the HTML

Ok, before we start writing some logic, let's get some HTML up and running:



const App = () => {
  const items = [
    "You asked if",
    "you could see me",
    "before I went to",
    "Spain, you didn't",
    "give a reason didn't",
    "know what you would",
    "say. But I was hoping",
    "that my breath on your",
    "face would blow every",
    "last thing into place",
  ];

  return (
    <div className='list'>
      {items.map((value, index) => (
        <div key={value} className="list-item">
          {value}
        </div>
      ))}
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

I put the list of items in a constant variable, but let's actually move it into a useState hook so we can edit it later:



const App = () => {
  const [items, setItems] = useState([
    "You asked if",
    "you could see me",
    "before I went to",
    "Spain, you didn't",
    "give a reason didn't",
    "know what you would",
    "say. But I was hoping",
    "that my breath on your",
    "face would blow every",
    "last thing into place",
  ]);
  ...
};


Enter fullscreen mode Exit fullscreen mode

Adding some CSS

As you can see, I already added the classes .list and .list-item, so let's use those to style our items:



.list {
  display: flex;
  flex-direction: column;

  justify-content: center;
  text-align: center;

  border: 1px black solid;

  width: min-content;
  margin: auto;
}

.list-item {
  padding: 0.3rem;
  white-space: nowrap;
  cursor: pointer;
  height: 1.5rem;
  background-color: white;
}


Enter fullscreen mode Exit fullscreen mode

We now have something that looks like the demo, but with no functionality. Let's get started with some of the actual logic:

Adding drag logic.

Ok, for our drag-n-drop behaviour, we are gonna first have to be able to drag any given item in our list, this should be simple enough, first, we have to keep track of a dragged item inside some variable, so let's make such a variable:



const App = () => {
  const [items, setItems] = useState([...]);
  const [dragged, setDragged] = useState<number | null>(null); // storing the dragged item as an index

  ...
};


Enter fullscreen mode Exit fullscreen mode

Now, we have to do three things:

  1. Set the currently dragged item when clicking on an object.
  2. Not render the dragged item
  3. Make a copy of the dragged item that follows the mouse.

The first and second are pretty straight forward, we have to use the onMouseDown event for the first one.
For the second one, we just need to use good ol' conditional rendering:



{items.map((value, index) => (
        <>
          {dragged !== index && (
            <div
              key={value}
              className="list-item"
              onMouseDown={(e) => {
                e.preventDefault();
                setDragged(index);
              }}
            >
              {value}
            </div>
          )}
        </>
      ))}


Enter fullscreen mode Exit fullscreen mode

Now clicking in any given item makes it disappear, in order to render a floating item, let's first add another div with an absolute position, we'll worry about how to make it follow the mouse later



return (
    <>
      {/* ----------FLOATING ITEM---------- */}
      {dragged !== null && (
        <div className="floating list-item">{items[dragged]}</div>
      )}

      {/* ----------MAIN LIST---------- */}
      <div className="list">
        ...
      </div>


Enter fullscreen mode Exit fullscreen mode

The .floating css class just looks like this:



.floating {
  position: absolute;
  box-shadow: 5px 5px 10px rgba(0,0,0,0.5);
}


Enter fullscreen mode Exit fullscreen mode

Now, in order to properly render the item below the mouse, we are gonna have to listen to the mousemove event on the entire document. We can use a useEffect for that:



  const [items, setItems] = useState([...]);
  const [dragged, setDragged] = useState<number | null>(null);

  const [mouse, setMouse] = useState<[number, number]>([0, 0]);

  // get mouse coordenates
  useEffect(() => {
    const handler = (e: MouseEvent) => {
      setMouse([e.x, e.y]);
    };

    document.addEventListener("mousemove", handler);

    return () => document.removeEventListener("mousemove", handler);
  }, []);
...


Enter fullscreen mode Exit fullscreen mode

Now that we are tracking the mouse, we can set the floating div's top and left properties to make it follow the mouse:



      {/* ----------FLOATING ITEM---------- */}
      {dragged !== null && (
        <div className="floating list-item"
        style={{
          left: `${mouse[0]}px`,
          top: `${mouse[1]}px`,
        }}
        >{items[dragged]}</div>
      )}


Enter fullscreen mode Exit fullscreen mode

Amazing, we have now a dragging functionality, but no way to drop any item, so let's work on that now:

Putting the 'drop' in 'drag-n-drop'

When we drop an item, we are given a place to drop it between two different items, for example, here:

Drag-n-drop demonstration

We are putting 'you could see me' between 'say. But I was hoping' and 'that my breath on your', so we need to have some sort of dropbox element between each item:



{/* ----------MAIN LIST---------- */}
<div className="list">
  <div className="list-item drop-zone" /> {/* Drop zone before all items */}
  {items.map((value, index) => (
    <>
      {dragged !== index && (
        <>
          <div
            key={value}
            className="list-item"
            onMouseDown={(e) => {
              e.preventDefault();
              setDragged(index);
            }}
          >
            {value}
          </div>
          <div className="list-item drop-zone" /> {/* drop zone after every item */}
        </>
      )}
    </>
  ))}
</div>


Enter fullscreen mode Exit fullscreen mode

This boxes kinda break the app because they are visible all of the time, so let's make them only visible while we are dragging something, to do that, we can replace their className with the following line:



className={`list-item drop-zone ${dragged === null ? "hidden" : ""}`}


Enter fullscreen mode Exit fullscreen mode

Now it should look like this:



<div
  className={`list-item drop-zone ${dragged === null ? "hidden" : ""}`}
/>
  {items.map((value, index) => (
    <>
      {dragged !== index && (
        <>
          <div
            ...
          >
            {value}
          </div>
          <div
            className={`list-item drop-zone ${
              dragged === null ? "hidden" : ""
            }`}
          />
        </>
      )}
    </>
  ))}
</div>


Enter fullscreen mode Exit fullscreen mode

Now we should write some CSS for .drop-zone and .drop-zone.hidden



.list-item.drop-zone {
  background-color: #ccc;
  transition-property: height padding;
  transition-duration: 250ms;
  transition-timing-function: cubic-bezier(0.075, 0.82, 0.165, 1);
  overflow: hidden;
}

.list-item.drop-zone.hidden {
  height: 0px;
  padding: 0px
}


Enter fullscreen mode Exit fullscreen mode

That would be good, except for the fact that all of the drop-zones are visible at the same time, in reality, only the closest one to the mouse should be.

The simplest way to fix that is to use some good ol' javascript to figure out which of the items is closest to us, so inside a new useEffect hook, we'll check the distance to the mouse for every drop-zone element:



  const [dropZone, setDropZone] = useState(0);

  // get closest drop zone
  useEffect(() => {
    if (dragged !== null) {
      // get all drop-zones
      const elements = Array.from(document.getElementsByClassName("drop-zone"));
      // get all drop-zones' y-axis position
      // if we were using a horizontally-scrolling list, we would get the .left property
      const positions = elements.map((e) => e.getBoundingClientRect().top);
      // get the difference with the mouse's y position
      const absDifferences = positions.map((v) => Math.abs(v - mouse[1]));

      // get the item closest to the mouse
      let result = absDifferences.indexOf(Math.min(...absDifferences));

      // if the item is below the dragged item, add 1 to the index
      if (result > dragged) result += 1;

      setDropZone(result);
    }
  }, [dragged, mouse]);


Enter fullscreen mode Exit fullscreen mode

The code and the comments should be pretty self-explanatory, with, maybe, the exception of this line:



// if the item is below the dragged item, add 1 to the index
if (result > dragged) result += 1;


Enter fullscreen mode Exit fullscreen mode

We have to add 1 to the items dragged because this code does not account for the box right below the currently dragged item, as that box is currently non-existant (because of the conditional rendering we added earlier), our index will be 1 number too low if we are above the currently dragged item.

Now that we have figured out the closest item, we can make sure only said item is rendered, so we are gonna change the className line again, this time, from this:



className={`list-item drop-zone ${dragged === null ? "hidden" : ""}`}


Enter fullscreen mode Exit fullscreen mode

to this:



className={`list-item drop-zone ${dragged === null || dropZone !== index+1 ? "hidden" : ""}`}


Enter fullscreen mode Exit fullscreen mode

Now, our code looks like this:



<div className="list">
  <div
    className={`list-item drop-zone ${
      dragged === null || dropZone !== 0 ? "hidden" : ""
    }`}
  />
  {items.map((value, index) => (
    <>
      {dragged !== index && (
        <>
          <div
            ...
          >
            {value}
          </div>
          <div
            className={`list-item drop-zone ${dragged === null || dropZone !== index + 1 ? "hidden" : ""}`}
          />
        </>
      )}
    </>
  ))}
</div>


Enter fullscreen mode Exit fullscreen mode

As you can see, I used index+1 to account for the '0th' zone before all items.

Ok, this leaves us with the following:

Demo of dragging

Yep, it's still impossible to drop an item, but the rendering works as expected, now, to drop an item, we just need to add a mouseup listener to the document to know when we are dropping the item, so let's do just that inside a new useEffect



// drop item
  useEffect(() => {
    const handler = (e: MouseEvent) => {
      if (dragged !== null) {
        e.preventDefault();
        setDragged(null);
      }
    };

    document.addEventListener("mouseup", handler);
    return () => document.removeEventListener("mouseup", handler);
  });


Enter fullscreen mode Exit fullscreen mode

This allows us to drop items, but it doesn't really reorder the list just yet, once we figure out how to reorder the list, the component will be finished.

Reordering the list.

Before we can write a function for reordering the list, we need to figure some stuff out:

  1. When should we reorder the list
  2. What information about the list do we need.
  3. Where we can get that information
  4. How to actually use that information to reorder the list.

Ok, so number one is pretty simple, we should reorder the list after we drop an item, so inside the mouseup handler.

Number two should be pretty simple too, we should just need two numbers, the index of the item's original position and the index of the item's final position.

Number 3 has also already been solved, the dragged variable has the original index and the dropZone variable has the new index.

So now, let's solve number 4 by looking at two examples:

Example 1, simple movement:

If we have a list like this:

  • Start of the list
    • dropzone 0
  • [0] ITEM A
    • dropzone 1
  • [1] ITEM B
    • dropzone 2
  • [2] ITEM C
    • dropzone 3
  • [3] ITEM D
    • dropzone 4
  • [4] ITEM E
    • dropzone 5

If we drag 'ITEM B' to 'dropzone 4', our dragged variable will be 1 and our dropZone variable will be 4.
The new order should be:

  • [0] ITEM A
  • [1] ITEM C
  • [2] ITEM D
  • [3] ITEM B
  • [4] ITEM E

So index 1 now has what was on index 2, index 2 now has what was on index 3 and index 3 now has what was on index 1, knowing what our variables should be, this function should do the trick:



const reorderList = <T,>(l: T[], start: number, end: number) => {
  const temp = l[start];

  for (let i=start; i<end; i++) {
    l[i] = l[i+1];
  }
  l[end - 1] = temp;

  return l;
};



Enter fullscreen mode Exit fullscreen mode

We can plug this item into our handler like so:



// drop item
  useEffect(() => {
    const handler = (e: MouseEvent) => {
      if (dragged !== null) {
        e.preventDefault();
        setDragged(null);

        setItems((items) => reorderList([...items], dragged, dropZone));
      }
    };

    document.addEventListener("mouseup", handler);
    return () => document.removeEventListener("mouseup", handler);
  });


Enter fullscreen mode Exit fullscreen mode

Ok, we can now push items forward on the list, except in a very specific case, let's look at example number 2 for that case:

Example 2: Backward movement

Now, let's look at the exact opposite situation, let's say, using the example list we defined above, that we want to move "ITEM D" into "dropzone 1" (dragged=3 and dropZone=1). The resulting list should look like this:

  • [0] ITEM A
  • [1] ITEM D
  • [2] ITEM B
  • [3] ITEM C
  • [4] ITEM E

The item in index 1 has what was in index 3, item in index 2 has what was in index 1 and item in index 3 has what was in index 2.

So it is similar but not quite the same, let's separate the algorithm into moving the items backward and forward.

First, move all of the previous reordering code into a new function and call it _reorderListForward (or something), now we'll redefine our reorderList function like so:



const reorderList = <T,>(l: T[], start: number, end: number) => {
  if (start < end) return _reorderListForward([...l], start, end);
  else if (start > end) return _reorderListBackward([...l], start, end);

  return l; // if start == end
};


Enter fullscreen mode Exit fullscreen mode

Now, for the _reorderListBackward function, as I said, any item in a given index should be equal to what used to be in the list on the previous index:



const _reorderListBackward = <T,>(l: T[], start: number, end: number) => {
  for (let i = start; i > end; i--) { // backward for-loop for backward movement
    l[i] = l[i - 1];
  }

  return l;
};


Enter fullscreen mode Exit fullscreen mode

Except for the end index, which should have the same value as start.



const _reorderListBackward = <T,>(l: T[], start: number, end: number) => {
  const temp = l[start];

  for (let i = start; i > end; i--) {
    l[i] = l[i - 1];
  }

  l[end] = temp;

  return l;
};


Enter fullscreen mode Exit fullscreen mode

Amazing! We can now reorder any item in any direction at any time! Yay!

Conclusion

Now the element is complete, it isn't perfect and I wouldn't call if production ready, but I think it's a really good starting place.

If you wanted to make this into a bigger project, why not try adding drag-handles? Or packaging this whole thing into a reusable component?

Or, you know, stop reinventing the wheel and just use a premade library that can do all that and more? Yeah, probably should have done that from the beginning... ¯\_(ツ)_/¯

💖 💪 🙅 🚩
h8moss
h8moss

Posted on January 8, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related