Build a reorderable list in react
h8moss
Posted on January 8, 2023
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:
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>
);
};
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",
]);
...
};
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;
}
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
...
};
Now, we have to do three things:
- Set the currently dragged item when clicking on an object.
- Not render the dragged item
- 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>
)}
</>
))}
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>
The .floating
css class just looks like this:
.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:
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:
{/* ----------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:
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>
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" : ""}`}
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>
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
}
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]);
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;
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" : ""}`}
to this:
className={`list-item drop-zone ${dragged === null || dropZone !== index+1 ? "hidden" : ""}`}
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>
As you can see, I used index+1
to account for the '0th' zone before all items.
Ok, this leaves us with the following:
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);
});
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:
- When should we reorder the list
- What information about the list do we need.
- Where we can get that information
- 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;
};
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);
});
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
};
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;
};
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;
};
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... ¯\_(ツ)_/¯
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
August 19, 2024