DnD Persistent Ordering in Firestore

Preface: This article assumes basic knowledge about React, React Beautiful DnD, Google Firebase/Firestore, and React Bootstrap. This is not a tutorial, but merely an informative article regarding my experience.

Demo @ playground

Before I begin, I wish to point out that I have recently created a new web app called playground. This app is hosted on Google Firebase, and will allow me to make use of the hosting and database services to demonstrate some of my code.

Order Amidst Chaos

Cloud Firestore by Google Firebase is a NoSQL document database that allows user to store documents in collections and create collections in documents. As such, documents in collections are unordered. Indexes can be created in Firestore to allow queried documents to be sorted by their fields. Indexes, however, are only useful if the fields in your data are representative of the order you wish to sort them in.

For instance, when fetching a list of personnel, it would make some sense to sort them by their names alphabetically, and if they had the same name, to further order them by age, maybe in descending order.

However, when apps wish to provide custom ordering, the fields would not make much sense for ordering.

Custom Ordering with Drag and Drop

Drag and Drop (DnD) UIs are very good and intuitive for custom ordering. One successful implementation of this is Trello, a kanban board style application for making lists. Users generally wish to order their data based on their preferred order, and there may never be an algorithm complex enough to tackle the human mind’s desire.

It would make sense to store these packets of data in a Firestore collection, as these data packets may be large. But since documents in the collection are unordered, a different approach would have to be taken.

Understanding React Beautiful DnD

When I first started using React Beautiful DnD, I relied on this article: How to Add Drag and Drop in React with React Beautiful DnD. This helped with setting up everything, configuring the Drag and Drop, as well as the Frontend persistence (meaning that your order is persisted after dropping).

However, with only frontend persistence, refreshing the web page would reset everything to their original order.

With reference to the article mentioned above, skipping all the way to Step 3, the code is as such:

const handleOnDragEnd = (result) => {
  if (!result.destination) return
  const items = Array.from(collection)
  const [reorderedItem] = items.splice(result.source.index, 1)
  items.splice(result.destination.index, 0, reorderedItem)
  setCollection(items)
}

From this, we can understand that when items in a collection are dragged and dropped, a standard array reordering method is used. The item is removed from the array from its original index, and placed back into the array at its new index. We can further deduce that since arrays have inherent ordering because of their indexes, as long as we consistently render out our collection based on this array that preserves ordering, our collection will be able to maintain its custom ordering.

Achieving Custom Ordering with Firestore

Data Model

Since users are able to create subcollections under documents in Firestore’s data model, in this article, we will call that document that contains the subcollection the representing document.

We will also assume that document IDs and fields do not hold any significance towards ordering. As such, using the randomly generated IDs by Firestore, or any ID generator library like UUID or shortid will work.

datamodel

In the screen above, for our DnD implementation, the representing document is dndlist, and it contains a subcollection of items, and each of the items are “data packets”.

Denormalising Data

You might have noticed an array called order within the representing document. This is the process of denormalising data in a NoSQL database. By storing the document IDs in an array, we are able to achieve ordering using indexes as mentioned before.

The choice of storing document IDs within the order array is for ease of rendering the items later.

DnD project @ playground

For demonstration purposes, I made a live web app, with the public source code on GitHub.

With the above data model in Firestore, we can fetch the representing document dndlist and set its order array. The code below shows how I perform my fetch using React’s useEffect and useState hooks.

const [order, setOrder] = useState([])
useEffect(() => {
  db.collection('projects').doc('dndlist').get().then((doc) => {
    if (doc.exists) {
      setOrder(doc.data().order)
    }
  })
}, [])

Since order is an array, we can use the map function to render out the list of Draggable cards in their order. Note that these cards have to be within a DragDropContext and Droppable component.

<DragDropContext onDragEnd={handleOnDragEnd}>
  <Droppable droppableId="droppable" key="dnd">
    {(provided) => (
      <div {...provided.droppableProps} ref={provided.innerRef}>
        {order && order.map((itemId, index) => (
          <DnDDraggable key={itemId} id={itemId} index={index} deleteFn={handleDelete}/>
        ))}
        {provided.placeholder}
      </div>
    )}
  </Droppable>
</DragDropContext>

In this case, itemId stands for each of the document’s ID, so they can be used as unique key props for children rendered by a map function. With the itemId passed as a prop to the Draggable component, we are able to use it to query our items subcollection, and fetch the contents of each unique document.

const [item, setItem] = useState("")
// Using itemId as id, as passed from props, shown above
useEffect(() => {
  db.collection('projects').doc('dndlist').collection('items').doc(id).get().then((doc) => {
    if (doc.exists) {
      setItem(doc.data().title)
    }
  })
}, [id])

For the sake of demonstration, my Draggable cards only display their title, but as documents can have up to 1MB of storage in Firestore, they can afford to store more fields/data. After getting the item/title, I am able to design my Draggable cards, as shown below:

<Draggable draggableId={id} index={index}>
  {(provided, snapshot) => (
    <Card className={snapshot.isDragging ? "mb-2 dragging" : "mb-2 not-drag"}
      ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
      <Card.Body>
        {item}
        <div style={{float: "right"}} onClick={() => deleteFn(id)}>
          x
        </div>
      </Card.Body>
    </Card>
  )}
</Draggable>

Functions To Persist Order

Now that we have successfully created a Drag and Drop UI, we will have to persist its order.

As mentioned earlier, the function written alone is able to persist the order in the Frontend, by using a standard array reordering method. In order to persist its order in the backend, all we have to do is update the order array with the updated items array created! Check out the code below:

const handleOnDragEnd = (result) => {
  if (!result.destination) {
    return
  }
  const items = Array.from(order)
  const [reorderedItem] = items.splice(result.source.index, 1)
  items.splice(result.destination.index, 0, reorderedItem)
  setOrder(items)
  // Update the order in the document for backend persistence
  db.collection('projects').doc('dndlist').update({
    order: items
  })
}

Because we used the map function of an array to render our content, this means that whenever we drag and drop an item, it will rearrange its order, and all we have to do is update this order in the backend.

Refresh and voila…

The order of the items in the dndlist should be as per how you left them after dragging and dropping. To further test this out, I have made create and delete features, and the list can have up to 20 items. For the code behind those features, it will be on my GitHub repository.

Conclusion

I wrote this article as I felt pretty helpless when it came to backend persistence of DnD ordering, especially because I was using a NoSQL database, and there did not seem to be any guides available out there. If there is anyone out there reading this, thank you!

Firebase and Firestore may be easy tools to pick up, but there are quite some limitations. Hopefully this can help someone.

Return to Posts