React - What a Drag

thursday, 25 may '23 @ 18:56 in Technology, JS+TS (last updated: sunday, 20 october '24 @ 11:55)

We came into a simple problem at work recently. Imagine a table of items that can be edited. The end user can amend each cell, add rows or remove rows. It's served the end user quite well for a time but eventually we stopped being able to skirt around the need to insert rows in the middle of the table, so we decided that being able to re-order the elements would be a great way to solve this.

So I made a small hook which can quite easily be used to create drag + drop interfaces for React web. TLDR; want to get straight to the point? Check it out below. Otherwise, this article is a quick introduction to creating drag and drop interfaces for web using React in particular.

or,visit the gist on Github

Check out the page to see it in action.

Why not use a library?

It depends on your use case. If you want to quickly implement something with a lot of functionality and to have an external guarantee about things like cross-browser support (especially mobile, which this particular hook doesn't do), then you might be better off reaching for a library. On the other hand, if...

  • You are interested in the browser API that underlies this and you have time to read up

  • Your use-case is sufficiently simple that it's not worth bloating your bundle size just to use a small portion of an otherwise elaborate library

  • You want full control over how this is implemented for your project

  • You do NOT need mobile browser support

...why not consider implementing a solution yourself?

A brief introduction to the HTML drag + drop API

Defining draggable items

HTML5 introduced the draggable attribute which is global, meaning it can be attached to literally any element in the DOM. Adding it to an element means clicking on it (or any of its children) and dragging will create a 'ghost' preview of the element and allow the user to move it around the page.

Defining drop-zones

Creating a drop-zone involves just involves adding an onDrop handler which defines what happens when an item is dropped onto the given element. Drop-zones don't themselves need to be draggable, but if they are, then items can be dropped onto themselves.

The drag + drop handlers

There are several other handlers that fire at different points in the drag + drop journey which can be used to fully craft the intended journey.

These events define the start/end of an entire single drag + drop event.

  • onDragStart - user clicks, holds onto and begins dragging an element around, fires once per drag usage.

  • onDrag - user is currently dragging something around, fires once every few hundred milliseconds while the action is ongoing.

  • onDrop - user has dropped what they were dragging around onto a valid DOM node.

  • onDragEnd - the drag action has finished, either because the item was dropped (successfully or otherwise) or because the user cancelled with the Esc key. This event always fires after onDrop.

These events can be used to track the current drop target while the user is dragging.

  • onDragEnter - the user is currently dragging something, and the cursor has entered this target.

  • onDragLeave - as above, but the cursor has left this target.

  • onDragExit - avoid this because it's been deprecated.

Adding the respective onAction prop to the component is probably the easiest way to add the callbacks in React, but depending on your framework/preference, you could opt to use addEventListener to achieve likewise.

The DataTransfer object

Drag events have a DataTransfer object associated with them which is where you'd store data such as which item is being dragged, which drag field the item belongs to, etcetera. Check out this article for details about how to use it. It's only possible to send a single stringified value in this way, so if you need to pass multiple values across, it becomes necessary to serialise them using JSON.stringify or similar.

Drag preview - when dragging an object, by default it uses a semi-transparent version of the dragged item to imply the current drag action. This can be substituted using DataTransfer's setDragImage method which can take either a DOM element or an image that will show in place of the drag preview.

Competing with other actions

An annoying thing about draggable items is that dragging at ANY point inside the entity will cause it to take flight. If inside you have elements where the user should be able to highlight text (this could simply be a text-block, or an <input/>) then this stops being possible. One can consider...

  • Attaching the drag callbacks to a 'handle' inside the parent to stop the drag action triggering on the inputs. Or...

  • Add the draggable attribute to the inner tag with an onDragStart handler that prevents the parent from dragging (example).

It's not a very pretty solution, but it is at least easy to implement. If this does end up a pain, there's a chance the draggable item itself is a bit too complicated. Alternatively, if you have figured out a better way around this, please let me know!

So how does one use this hook?

useDraggableField is generic, where <T> is the type for the identifier of each element in the rearrangeable list. It defaults to 'number' (e.g. for the item's index) but could be a string ID or anything else, the type can be inferred from the signature of onRearrange.

It takes two arguments - fieldName, a unique string to define the field (similar to radio field, where you might have several radio fields in a form for different questions), and the onRearrange callback which takes the identifier of the dragged item and an identifier of the dropped one. If using indexes as identifiers, this could just be a method that removes the item at the dragged index and inserts it at the dropped one, but it could do anything (especially if allowing to drag/drop between multiple lists).

An earlier version of this hook returned each handler individually which had to be manually inspired by react-hook-form's register function, I opted to return methods which can spread the draggable or drop-zone props into any element. It also exposes the types of the props so they can be used for the props of React components.

  • draggableItemProps also takes an optional 'ghost' argument which takes a JSX element for the drag preview.

For examples of all of the above, take a look at the page.

What about mobile devices?

Whilst drag and drop is technically supported on some mobile browsers, leveraging the events won't work the same way because dragging on touch screens is usually associated with scrolling the page rather than pulling elements around. In this case, one usually needs to utilise touch events to build a similar experience. My hook doesn't have any support for this so, as mentioned near the start of the article, if that's a necessary use-case, it might be easier to use a library.

That said, do be careful when implementing such things on mobile - it can be annoying for users to try to scroll down a page only to accidentally grab elements and start moving them around the screen.

To summarise...

I thought I'd write about this because it took me a few hours to devise a quick, re-usable system that could be easily used to solve the use-case described at the beginning of the post. Obviously I hope that, even to people not using React, there's enough information here to do likewise using the HTML's draggable attribute. Have you found anything here useful? Perhaps you have an improvement to suggest to my approach? Please do get in touch - I'd love to hear about it! 😊

-tommy

Thanks for reading! If you enjoyed reading this post and/or learned something, please get in touch and let me know. Better yet, if you've found any errata in my blog posts, please do make me aware. I'm always looking for opportunities to improve my writing!

Footnotes