DEV Community

Josh Ellis
Josh Ellis

Posted on

CodeAlong: Multi-Column Drag and Drop in React

I've been working on a productivity app, and like many of its siblings, drag-and-drop is a key feature for moving items around within the app.

After looking into and trying a few options, I settled on react-beautiful-dnd. Here's a generic example of how to use it to add drag and drop into your own project.

Follow along. Starter code is available on GitHub or Code Sandbox! Just check out the start here branch.

✨️ What We're Building

demo gif

Live Demo

Code Sandbox

💾️ Installation

To start, create a fresh install of create-react-app:

npx create-react-app drag-and-drop --typescript
Enter fullscreen mode Exit fullscreen mode

The --typescript flag is optional, but I prefer TypeScript, so I'll be using that. This should work just fine without TypeScript. We're ready to install react-beautiful-dnd:

yarn add react-beautiful-dnd
Enter fullscreen mode Exit fullscreen mode

If using TypeScript, you'll want the types as well: yarn add @types/react-beautiful-dnd

After the tutorial, I'll be using stitches to make the live demo pretty. This tutorial won't cover styling, but if you want to install stitches, check out the installation docs.

🚀️ Getting Started

The first thing to do is clean out App.tsx:

// src/App.tsx
import React from 'react'

function App () {
  return <div></div>
}

export default App
Enter fullscreen mode Exit fullscreen mode

From here, let's create a simple single-column list using divs and temporary, ugly inline styling:

// src/App.tsx

function App () {
  return (
    <div
      style={{
        display: 'flex',
        margin: '24px auto',
        maxWidth: '128px',
        flexDirection: 'column',
        justifyContent: 'space-between'
      }}
    >
      <div>Item 1</div>
      <div>Item 2</div>
      <div>Item 3</div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The result is a very underwhelming and ugly list. Let's keep moving.

⚙️ Components Time

I know it doesn't look like much yet, but things are about to get complicated, so we need to set up components. First, lets make an <Item> component and replace the hard-coded list with an array and map:

// src/App.tsx
import Item from './components/Item'

function App () {
  const list = ['Item 1', 'Item 2', 'Item 3']
  return (
    <div
      //...style...
     >
      {list.map((text, index) => (
        <Item key={text} text={text} index={index} />
      ))}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode
// src/Item.tsx
import React from 'react'

// TypeScript only
interface ItemProps {
  text: string
  index: number
}

// ": React.FC<ItemProps>" is TypeScript only
const Item: React.FC<ItemProps> = ({ text, index }) => {
  return <div>{text}</div>
}

export default Item
Enter fullscreen mode Exit fullscreen mode

Note: We don't need the index yet, but we will.

Next, to prep for multi-column we'll create a <Column> component as well:

// src/components/Column.tsx
import React from 'react'
import Item from './Item'

// TypeScript only
interface ColumnProps {
  list: string[]
}

// ": React.FC<ItemProps>" is TypeScript only
const Column: React.FC<ColumnProps> = ({ list }) => {
  return (
    <div
      style={{
        display: 'flex',
        flexDirection: 'column'
      }}
    >
      {list.map((text, index) => (
        <Item key={text} text={text} index={index} />
      ))}
    </div>
  )
}

export default Column
Enter fullscreen mode Exit fullscreen mode

Let's update App.tsx with the new component. We'll still only have one column for now, but we can set up the inline styling for a three-column grid while we're at it:

// src/App.tsx
import Column from './components/Column'

function App () {
  const list = ['Item 1', 'Item 2', 'Item 3']
  return (
    <div
      style={{
        display: 'grid',
        gridTemplateColumns: '1fr 1fr 1fr',
        margin: '24px auto',
        width: '80%',
        gap: '8px'
      }}
    >
      <Column list={list} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

🔮️ Adding Drag and Drop

The moment you've been waiting for.

The react-beautiful-dnd package expects a syntax you might not have seen before. It uses components that expect a function as its child. That function then returns JSX/TSX containing the element you want to make droppable/draggable.

This is because the function has an argument called provided that needs to be passed as props to the droppable/draggable elements. (It also allows use of a second, more advanced argument that we won't need today.)

This will all make more sense in a minute...

First, we need to wrap everything in a <DragDropContext>. We also need to make a onDragEnd function that we'll be using later.

// src/App.tsx
import {DragDropContext} from 'react-beautiful-dnd'
/* ... */
  const onDragEnd = () => null

  return (
    <DragDropContext onDragEnd={onDragEnd}>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: '1fr 1fr 1fr',
          margin: '24px auto',
          width: '80%',
          gap: '8px'
        }}
      >
        <Column list={list} />
      </div>
    </DragDropContext>
  )
  /* ... */
Enter fullscreen mode Exit fullscreen mode

Next, we need to convert Column into a <Droppable>, add provided.props and provided.innerRef to the returned <div> and add provided.placeholder:

// src/components/Column.tsx
import { Droppable } from 'react-beautiful-dnd'

const Column: React.FC<ColumnProps> = ({ list }) => {
  return (
    <Droppable droppableId='col-1'>
      {provided => (
        <div
          style={{
            display: 'flex',
            flexDirection: 'column'
          }}
          {...provided.droppableProps}
          ref={provided.innerRef}
        >
          {list.map((text, index) => (
            <Item key={text} text={text} index={index} />
          ))}
          {provided.placeholder}
        </div>
      )}
    </Droppable>
  )
}
Enter fullscreen mode Exit fullscreen mode

Finally, we do a similar thing with Item, turning it into a <Draggable> and adding provided.innerRef, provided.dragHandleProps, and provided.draggableProps to the div:

// src/components/Item.tsx
import { Draggable } from 'react-beautiful-dnd'

const Item: React.FC<ItemProps> = ({ text, index }) => {
  return (
    <Draggable draggableId={text} index={index}>
      {provided => (
        <div
          ref={provided.innerRef}
          {...provided.draggableProps}
          {...provided.dragHandleProps}
        >
          {text}
        </div>
      )}
    </Draggable>
  )
}
Enter fullscreen mode Exit fullscreen mode

The provided.dragHandleProps allows you to have a specific part of the element be the handle, but we're going to keep it simple and have the whole element as the handle.

🎉️ It works! But not really

So by now you'll have something that looks like this:

half-working drag and drop

The items are draggable and they seem to move around correctly, but when you drop an item, everything just goes back to how it was. That's because we haven't introduced state into the equation yet. Remember onDragEnd?

That function is executed exactly when you'd expect: at the end of the drag. It has access to source and destination objects, which have useful information for updating our state.

But first, let's make our list stateful with a useState() hook:

// src/App.tsx
/* ... */
const [list, setList] = useState(['Item 1', 'Item 2', 'Item 3'])
/* ... */
Enter fullscreen mode Exit fullscreen mode

If you're not familiar with hooks, check out the official docs.

Our goal is to update the list every time an item is dropped, so let's start writing onDragEnd:

// src/App.tsx
/* ... */
const [list, setList] = useState(['Item 1', 'Item 2', 'Item 3'])

  const onDragEnd = ({ source, destination }: DropResult) => {
    // Make sure we have a valid destination
    if (destination === undefined || destination === null) return null

    // Make sure we're actually moving the item
    if (destination.index === source.index) return null

    // Move the item within the list
    // Start by making a new list without the dragged item
    const newList = list.filter((_: any, idx: number) => idx !== source.index)

    // Then insert the item at the right location
    newList.splice(destination.index, 0, list[source.index])

    // Update the list
    setList(newList)
  }
/* ... */
Enter fullscreen mode Exit fullscreen mode

The comments in that snippet are hopefully self explanatory. The list now retains its order!

working single col

If all you need is a single list, you're done!

🕳 Multiple Drop Zones

But we're not done here yet! Let's add a few more columns.

First, we need to upgrade the way we keep track of state:

// src/App.tsx
/* ... */
const initialColumns = {
    todo: {
      id: 'todo',
      list: ['item 1', 'item 2', 'item 3']
    },
    doing: {
      id: 'doing',
      list: []
    },
    done: {
      id: 'done',
      list: []
    }
  }
  const [columns, setColumns] = useState(initialColumns)
  /*...*/
Enter fullscreen mode Exit fullscreen mode

As you can see, we now have three columns, each with an id and its own list. We'll use the IDs in a minute. In a more complex app, you might also have a title field on each column and use a different kind of ID, but we're keeping it simple for now.

Let's update App's return to map through the columns:

// src/App.tsx
/* ... */
  {Object.values(columns).map(col => (
    <Column col={col} key={col.id} />
  ))}
/* ... */
Enter fullscreen mode Exit fullscreen mode

We changed the props to just be col because I prefer to just destructure the object on the other end:

// src/components/Column.tsx
/* ... */
// TypeScript only
interface ColumnProps {
  col: {
    id: string
    list: string[]
  }
}

const Column: React.FC<ColumnProps> = ({ col: { list, id } }) => {
  return (
    <Droppable droppableId={id}>
      {provided => (
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
          }}
          >
          <h2>{id}</h2>
          <div
            style={{
              display: 'flex',
              flexDirection: 'column',
              minHeight: '120px'
            }}
            {...provided.droppableProps}
            ref={provided.innerRef}
          >
            {list.map((text, index) => (
              <Item key={text} text={text} index={index} />
            ))}
            {provided.placeholder}
          </div>
        </div>
      )}
    </Droppable>
  )
}
/* ... */
Enter fullscreen mode Exit fullscreen mode

Note: I added a header here, so I had to adjust the divs a bit.

When using multiple columns, it's important to have a minimum height on the element that takes provided.droppableProps.

😢 We Broke It (again)

You should now have three columns, and the first column should have three items in it. Dragging works, but the items go back to where they were.

That's because we need to update the onDragEnd function to handle our new setup.

First, let's update the single column case:

// src/App.tsx
/* ... */
const onDragEnd = ({ source, destination }: DropResult) => {
    // Make sure we have a valid destination
    if (destination === undefined || destination === null) return null

    // If the source and destination columns are the same
    // AND if the index is the same, the item isn't moving
    if (
      source.droppableId === destination.droppableId &&
      destination.index === source.index
    )
      return null


    // Set start and end variables
    const start = columns[source.droppableId]
    const end = columns[destination.droppableId]

    // If start is the same as end, we're in the same column
    if (start === end) {
      // Move the item within the list
      // Start by making a new list without the dragged item
      const newList = start.list.filter(
        (_: any, idx: number) => idx !== source.index
      )

      // Then insert the item at the right location
      newList.splice(destination.index, 0, start.list[source.index])

      // Then create a new copy of the column object
      const newCol = {
        id: start.id,
        list: newList
      }

      // Update the state
      setColumns(state => ({ ...state, [newCol.id]: newCol }))
      return null
    }
    return null
  }
/* ... */
Enter fullscreen mode Exit fullscreen mode

Again, the comments should explain the above code. Note: make sure you updated the second if block!

If all is well, single column drag/drop should be working now.

Finally, let's set up multi-column drag and drop:

// src/App.tsx
/* ... */
const onDragEnd = ({ source, destination }: DropResult) => {

    /* ... */

    // If start is the same as end, we're in the same column
    if (start === end) {
      /* ... */
    } else {
      // If start is different from end, we need to update multiple columns
      // Filter the start list like before
      const newStartList = start.list.filter(
        (_: any, idx: number) => idx !== source.index
      )

      // Create a new start column
      const newStartCol = {
        id: start.id,
        list: newStartList
      }

      // Make a new end list array
      const newEndList = end.list

      // Insert the item into the end list
      newEndList.splice(destination.index, 0, start.list[source.index])

      // Create a new end column
      const newEndCol = {
        id: end.id,
        list: newEndList
      }

      // Update the state
      setColumns(state => ({
        ...state,
        [newStartCol.id]: newStartCol,
        [newEndCol.id]: newEndCol
      }))
      return null
    }
  }
/* ... */
Enter fullscreen mode Exit fullscreen mode

That's a decent chunk of code, but the idea is simple. We're updating the lists of each column, then updating the state.

🥳️ It Works!

The final product works perfectly! With a little styling, you might end up with something like this:

demo gif

Live DemoCode Sandbox

💬️ Let's Talk

If you made it this far, thanks for reading! Will you be using react-beautiful-dnd in the future?

I'd love to see what you make.

Consider following me here on Dev.to if you want more content like this.

Next week, I'll be back with another article related to my upcoming productivity app. Stay tuned!

Top comments (7)

Collapse
 
aliplutus profile image
aliplutus

Do you know how to create recursive multi-columns?
in the following code sandbox I tried to create a sortable tree with react-beautiful-dnd but I don't know to finish it.
codeSandBox

Collapse
 
imjoshellis profile image
Josh Ellis • Edited

I haven't done recursive, so I'm not 100% sure what you're looking for.

But does this example of vertical nested lists help (I didn't make this, it's from the official docs/repo)?

Demo - Code on GH

Collapse
 
aliplutus profile image
aliplutus • Edited

recursive tree means that you can drag any element and drop it inside other elements.
dev-to-uploads.s3.amazonaws.com/i/...

Collapse
 
gustavojleal profile image
Gustavo Leal

I tried to use on typescript but I got this error when I call columns.
// Set start and end variables App.tsx
const start = columns[source.droppableId]
const end = columns[destination.droppableId]
TypeScript error in /home/gustavo/drag-and-drop/src/App.tsx(37,19):
Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ todo: { id: string; list: string[]; }; doing: { id: string; list: never[]; }; done: { id: string; list: never[]; }; }'.
No index signature with a parameter of type 'string' was found on type '{ todo: { id: string; list: string[]; }; doing: { id: string; list: never[]; }; done: { id: string; list: never[]; }; }'. TS7053.

Some idea how to fix it ?

Collapse
 
imjoshellis profile image
Josh Ellis

If you show me your repo, I can give you a more specific answer, but I think what's happening is Typescript thinks the object key should be from a very specific list ("todo"|"doing"|"done", for example) but you (or the library) are giving it a string...

See this for some examples of how to fix: stackoverflow.com/questions/568334...

Collapse
 
gustavojleal profile image
Gustavo Leal

Actually I tried to add your DND on my profile project, but I got this error. So I copy and pasted your code for try to understand if it was an under spelling mistake but I got the same error.
github.com/gustavojleal/dnd-react-....

Thanks so much for your time,

Collapse
 
urosran profile image
Uros Randelovic

Really good walkthrough mate!