Categories
React

How to add Drag and Drop features to your React app

Spread the love

Drag and drop is a commonly used feature in web apps. It provides the benefit of an intuitive way to manipulate data. React is a great library to use for building your web UI since it has high quality drag and drop libraries written for it in the form of React components. react-beautiful-dnd is written by Atlassian, the creator of JIRA task management system. It is a drag and drop library that is one of the easiest to incorporate into your app.

In this story, we will build a to-do list which incorporates drag and drop capability. We have a home page with a form to create a task at the top, then below it, there will be 2 lists side by side, with the left list displaying the to do item, and the right list displaying the done items. There will also be a way for user to delete the task from any of the 2 lists. A Redux store will be used to store the whole to do list.

To start building the app, we use one of the easiest way possible, the Create React App program from Facebook. To use it, we run npx create-react-app todo-app . This will create the project folder with the initial app code inside.

Next we install some libraries that we need. We need a HTTP client, the react-beautiful-dnd library, Bootstrap, Redux and React Router. We need a HTTP client to make HTTP requests, Bootstrap makes styling easy, and we use React Router for client side routing. We run npm i axios bootstrap react-bootstrap formik yup react-beautiful-dnd react-router-dom react-redux to install the libraries. Axios is our HTTP client, and Formik and Yup are form validation libraries that can be used with React Bootstrap to save some effort for creating the form.

With all the libraries we need installed, we can start writing the app. We put everything in the src folder unless mentioned otherwise. To start, we create a file called actionCreator.js and add:

import { SET_TASKS } from './actions';

const setTasks = (tasks) => {
    return {
        type: SET_TASKS,
        payload: tasks
    }
};

export { setTasks };

and we create action.js and in it, we put:

const SET_TASKS = 'SET_TASKS';

export { SET_TASKS };

These 2 files together create the action we dispatch to the Redux store which we will build shortly.

Next in App.js , we replace what is there with the following:

import React from 'react';
import { Router, Route, Link } from "react-router-dom";
import HomePage from './HomePage';
import { createBrowserHistory as createHistory } from 'history'
import Navbar from 'react-bootstrap/Navbar';
import Nav from 'react-bootstrap/Nav';
import './App.css';
const history = createHistory();

function App() {
  return (
    <div className="App">
      <Router history={history}>
        <Navbar bg="primary" expand="lg" variant="dark" >
          <Navbar.Brand href="#home">Drag and Drop App</Navbar.Brand>
          <Navbar.Toggle aria-controls="basic-navbar-nav" />
          <Navbar.Collapse id="basic-navbar-nav">
            <Nav className="mr-auto">
              <Nav.Link href="/">Home</Nav.Link>
            </Nav>
          </Navbar.Collapse>
        </Navbar>
        <Route path="/" exact component={HomePage} />
      </Router>
    </div>
  );
}

export default App;

This adds the navigation bar on the top and let us display the routes we define on the bottom. Home page should be the only route.

In HomePage.js , we put:

import React from 'react';
import { useState, useEffect } from 'react';
import './HomePage.css';
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
import { connect } from 'react-redux';
import TaskForm from './TaskForm';
import { editTask, getTasks, deleteTask } from './requests';

function HomePage({ tasks }) {
  const [items, setItems] = useState([]);
  const [todoItems, setTodoItems] = useState([]);
  const [doneItems, setDoneItems] = useState([]);
  const [initialized, setInitialized] = useState(false);

  const onDragEnd = async (evt) => {
    const { source } = evt;
    let item = {};
    if (source.droppableId == "todoDroppable") {
      item = todoItems[source.index];
      item.done = true;
    }
    else {
      item = doneItems[source.index];
      item.done = false;
    }
    await editTask(item);
    await getTodos();
  };

  const setAllItems = (data) => {
    if (!Array.isArray(data)) {
      return;
    }
    setItems(data);
    setTodoItems(data.filter(i => !i.done));
    setDoneItems(data.filter(i => i.done));
  }

  const getTodos = async () => {
    const response = await getTasks();
    setAllItems(response.data);
    setInitialized(true);
  }

  const removeTodo = async (task) => {
    await deleteTask(task.id);
    await getTodos();
  }

  useEffect(() => {
    setAllItems(tasks);
    if (!initialized) {
      getTodos();
    }
  }, [tasks]);

  return (
    <div className="App">
      <div className='col-12'>
        <TaskForm />
        <br />
      </div>
      <div className='col-12'>
        <div className='row list'>
          <DragDropContext onDragEnd={onDragEnd}>
            <Droppable droppableId="todoDroppable">
              {(provided, snapshot) => (
                <div
                  className='droppable'
                  ref={provided.innerRef}
                >
                  &nbsp;
                  <h2>To Do</h2>
                  <div class="list-group">
                    {todoItems.map((item, index) => (
                      <Draggable
                        key={item.id}
                        draggableId={item.id}
                        index={index}
                      >
                        {(provided, snapshot) => (
                          <div
                            className='list-group-item '
                            ref={provided.innerRef}
                            {...provided.draggableProps}
                            {...provided.dragHandleProps}
                          >
                            {item.description}
                            <a onClick={removeTodo.bind(this, item)}>
                              <i class="fa fa-close"></i>
                            </a>
                          </div>
                        )}
                      </Draggable>
                    ))}
                  </div>
                  {provided.placeholder}
                </div>
              )}
            </Droppable>
            <Droppable droppableId="doneDroppable">
              {(provided, snapshot) => (
                <div
                  className='droppable'
                  ref={provided.innerRef}
                >
                  &nbsp;
                  <h2>Done</h2>
                  <div class="list-group">
                    {doneItems.map((item, index) => (
                      <Draggable
                        key={item.id}
                        draggableId={item.id}
                        index={index}
                      >
                        {(provided, snapshot) => (
                          <div
                            className='list-group-item'
                            ref={provided.innerRef}
                            {...provided.draggableProps}
                            {...provided.dragHandleProps}
                          >
                            {item.description}
                            <a onClick={removeTodo.bind(this, item)}>
                              <i class="fa fa-close"></i>
                            </a>
                          </div>
                        )}
                      </Draggable>
                    ))}
                  </div>
                  {provided.placeholder}
                </div>
              )}
            </Droppable>
          </DragDropContext>
        </div>
      </div>
    </div>
  );
}

const mapStateToProps = state => {
  return {
    tasks: state.tasks,
  }
}

export default connect(
  mapStateToProps,
  null
)(HomePage);

This is where the drag and drop logic resides. We have 2 Droppable components where we have the 2 lists to store the to do and done items. The only drag and drop handler we need is the onDragEnd handler. This is where we update the done flag of the task item according to the source where the item is dragged from. If we drag from the Droppable component with the droppableId of todoDroppable , then we set done to true . Otherwise, we set done to false . After that is done, we update the task, then get the latest tasks, and display them. We also have a function to remove a task called removeTodo where we can remove a task. Once it’s removed the latest list will be obtained from back end. We have setAllItems function to distribute the the tasks into the to do and done item lists.

In the useEffect function, we have a second argument with the array with tasks as the argument. This is for triggering the useEffect callback to be called the tasks changes. The same thing can be used for handling any prop change, if you replace tasks with some other prop.

In HomePage.css , we put:

.item {
  padding: 20px;
  border: 1px solid black;
  width: 40vw;
}

.list {
  margin-left: 0px;
}

.list-group-item {
  display: flex;
  justify-content: space-between;
}

.list-group-item a {
  cursor: pointer;
}

.droppable {
  min-height: 100px;
  width: 40vw;
  margin-right: 9vw;
}

to style our lists by adding some spacing to them.

For add tasks, we have a dedicated TaskForm component to add a task. We create a file called TaskForm.js and add:

import React from 'react';
import { Formik } from 'formik';
import Form from 'react-bootstrap/Form';
import Col from 'react-bootstrap/Col';
import Button from 'react-bootstrap/Button';
import * as yup from 'yup';
import { addTask, getTasks } from './requests';
import { connect } from 'react-redux';
import { setTasks } from './actionCreators';
import './TaskForm.css';

const schema = yup.object({
    description: yup.string().required('Description is required'),
});

function ContactForm({ setTasks }) {
    const handleSubmit = async (evt) => {
        const isValid = await schema.validate(evt);
        if (!isValid) {
            return;
        }
        await addTask(evt);
        const response = await getTasks();
        setTasks(response.data);
    }

    return (
        <div className="form">
            <Formik
                validationSchema={schema}
                onSubmit={handleSubmit}
            >
                {({
                    handleSubmit,
                    handleChange,
                    handleBlur,
                    values,
                    touched,
                    isInvalid,
                    errors,
                }) => (
                        <Form noValidate onSubmit={handleSubmit}>
                            <Form.Row>
                                <Form.Group as={Col} md="12" controlId="firstName">
                                    <Form.Label>
                                        <h4>Add Task</h4>
                                    </Form.Label>
                                    <Form.Control
                                        type="text"
                                        name="description"
                                        placeholder="Task Description"
                                        value={values.description || ''}
                                        onChange={handleChange}
                                        isInvalid={touched.description && errors.description}
                                    />
                                    <Form.Control.Feedback type="invalid">
                                        {errors.description}
                                    </Form.Control.Feedback>
                                </Form.Group>
                            </Form.Row>
                            <Button type="submit" style={{ 'marginRight': '10px' }}>Save</Button>
                        </Form>
                    )}
            </Formik>
        </div>
    );
}

ContactForm.propTypes = {
}

const mapStateToProps = state => {
    return {
        tasks: state.tasks,
    }
}

const mapDispatchToProps = dispatch => ({
    setTasks: tasks => dispatch(setTasks(tasks))
})

export default connect(
    mapStateToProps,
    mapDispatchToProps
)(ContactForm);

We have the add task form with form validation to check if the description is filled in. Once it’s valid, we submit it to back end, then get the latest tasks and store them in our Redux store. That is all done in the handleSubmit function. Notice that we just create a schema object with the Yup library and then pass that into the validationSchema object of the Formik component, then the Formik component provides the handleChange function, values object, and errors object which we use in our React Bootstrap form directly, saving us from writing all the change handler code ourselves. Also we need || ‘’ at the end of values prop so that the value prop stays defined all time, preventing the triggering of uncontrolled input errors. The mapStateToProps at the bottom of the file map the tasks state in the store to the tasks props of our component and mapDispatchToProps maps our setTasks dispatch function which we use to update the store with tasks to the props of the TaskForm component.

Next in index.js , we replace the existing code with:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { tasksReducer } from './reducers';
import { Provider } from 'react-redux'
import { createStore, combineReducers } from 'redux'

const taskskApp = combineReducers({
    tasks: tasksReducer,
})

const store = createStore(taskskApp);

ReactDOM.render(
    <Provider store={store}>
        <App />
    </Provider>
    , document.getElementById('root'));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

to so that we can use the Redux store that we created in our app.

Then we create a file called reducers.js and add:

import { SET_TASKS } from './actions';

function tasksReducer(state = {}, action) {
    switch (action.type) {
        case SET_TASKS:
            state = JSON.parse(JSON.stringify(action.payload));
            return state;
        default:
            return state
    }
}

export { tasksReducer };

so that we can store our tasks into the store.

Next we make a file called requests.js to store the code for the functions for the HTTP requests that we make. We add:

const APIURL = 'http://localhost:3000';
const axios = require('axios');
export const getTasks = () => axios.get(`${APIURL}/tasks`);

export const addTask = (data) => axios.post(`${APIURL}/tasks`, data);

export const editTask = (data) => axios.put(`${APIURL}/tasks/${data.id}`, data);

export const deleteTask = (id) => axios.delete(`${APIURL}/tasks/${id}`);

to let us do CRUD operations on our tasks. It is used in HomePage and TaskForm component.

In public/index.html , we replace the existing code with:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8" />
  <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="theme-color" content="#000000" />
  <meta name="description" content="Web site created using create-react-app" />
  <link rel="apple-touch-icon" href="logo192.png" />
  <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
  <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
  <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
  <title>React Drag and Drop App</title>
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
    integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous" />
  <link href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet"
    integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">
</head>

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root"></div>
  <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
</body>

</html>

We added the Bootstrap CSS and Font Awesome icon so that we get the Bootstrap style and the close icon that we used in the HomePage ‘s i tag.

To start back end, we first install the json-server package by running npm i json-server . Them go to our project folder and run:

json-server --watch db.json

In db.json , change the text to:

{
  "tasks": [
  ]
}

so that we have the tasks endpoints defined in requests.js available.

By John Au-Yeung

Web developer specializing in React, Vue, and front end development.

Leave a Reply

Your email address will not be published. Required fields are marked *