Build a reorderable list in react
Tags: react, tutorial, typescript
Published: 2023-01-08 22:17:54

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/v1thkiqpxs7ii8jaulu6.gif)

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

{% embed https://codesandbox.io/embed/proud-river-0w57zl?fontsize=14&hidenavigation=1&theme=dark %}

(In the demo, we are reordering the lyrics of the song [tiptoe through the true bits by Los Campesinos!](https://open.spotify.com/track/5A5HRLWrxC5JOT348FHvLa?si=4107f75924c54c63) 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:
```tsx
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>
  );
};
```
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:
```tsx
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",
  ]);
  ...
};
```
## 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:
```css
.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;
}
```
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:

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

  ...
};
```
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:
```tsx
{items.map((value, index) => (
        <>
          {dragged !== index && (
            <div
              key={value}
              className="list-item"
              onMouseDown={(e) => {
                e.preventDefault();
                setDragged(index);
              }}
            >
              {value}
            </div>
          )}
        </>
      ))}
```
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
```tsx
return (
    <>
      {/* ----------FLOATING ITEM---------- */}
      {dragged !== null && (
        <div className="floating list-item">{items[dragged]}</div>
      )}

      {/* ----------MAIN LIST---------- */}
      <div className="list">
        ...
      </div>
```
The `.floating` css class just looks like this:
```css
.floating {
  position: absolute;
  box-shadow: 5px 5px 10px rgba(0,0,0,0.5);
}
```
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:

```tsx
  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);
  }, []);
...
```
Now that we are tracking the mouse, we can set the floating div's `top` and `left` properties to make it follow the mouse:
```tsx
      {/* ----------FLOATING ITEM---------- */}
      {dragged !== null && (
        <div className="floating list-item"
        style={{
          left: `${mouse[0]}px`,
          top: `${mouse[1]}px`,
        }}
        >{items[dragged]}</div>
      )}
```
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/45xhaceur2impzfzm469.gif)

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:

```tsx
{/* ----------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>
```

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:
```tsx
className={`list-item drop-zone ${dragged === null ? "hidden" : ""}`}
```
Now it should look like this:
```tsx
<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>
```
Now we should write some CSS for `.drop-zone` and `.drop-zone.hidden`

```css
.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
}
```
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:

```tsx
  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]);
```
The code and the comments should be pretty self-explanatory, with, maybe, the exception of this line:
```tsx
// if the item is below the dragged item, add 1 to the index
if (result > dragged) result += 1;
```
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:
```tsx
className={`list-item drop-zone ${dragged === null ? "hidden" : ""}`}
```

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

Now, our code looks like this:
```tsx
<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>
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/epkndwcubbs9shwrwcud.gif)

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`

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

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

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:

```tsx
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;
};

```
We can plug this item into our handler like so:

```tsx
// 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);
  });
```
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:
```ts
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
};
```

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:

```ts
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;
};
```
Except for the `end` index, which should have the same value as `start`.
```ts
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;
};
```
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... ¯\\\_(ツ)\_/¯