Tagged with

Often the best user experience is created with the fewest UI elements. In the case of ordering a list, having items be drag and drop instead of clunky up and down buttons can often be a much better choice. Let’s look at adding drag and drop to a React app using react-dnd! We will start by creating a new React Application using create-react-app.

  create-react-app react-drag-drop
  cd react-drag-drop

Adding Redux

We will use redux to manage our application state.

  yarn add redux react-redux redux-thunk immutable

We add immutable, because we will use immutable in our state. Also, we want to use flow, so, let's run: flow init and it creates a .flowconfig file (I already have flow-bin installed).

index.js

We start our implementation from our index.js, we connect our component with Redux and we provide our store.

  import React from "react";
  import ReactDOM from "react-dom";
  import "./index.css";
  import App from "./App";
  import registerServiceWorker from "./registerServiceWorker";
  import { DragDropStore } from "./stores";
  import { Provider } from "react-redux";

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

Store

Let's see how our store looks:

src/stores.js

We have a stores file that contains all our stores we might have. In our case, we just have one store, our DragDropStore:

  import DragDropStore from "./stores/DragDropStore";

  export { DragDropStore };

Here is our store. We connect with ReduxDevTools, if the browser supports it. We also pass our model, in this case it is a flow type called Model.

  // @flow

  import { DragDropReducer } from "../reducers";
  import { Model } from "../types";
  import { createStore, applyMiddleware, compose } from "redux";
  import thunk from "redux-thunk";

  const init = new Model();

  function composeWithApplyMiddlewares() {
    if (window.__REDUX_DEVTOOLS_EXTENSION__) {
      return compose(
        applyMiddleware(thunk),
        window.__REDUX_DEVTOOLS_EXTENSION__()
      );
    }
    return compose(applyMiddleware(thunk));
  }

  const createDragDropStore = (model: Model = init) => {
    return createStore(DragDropReducer, model, composeWithApplyMiddlewares());
  };

  const DragDropStore = createDragDropStore(init);

  export default DragDropStore;

  export { DragDropStore };

Model

We have a file where we import all our types. Our types are Immutable Records.

  src/types/index.js

Our Model has a list of items and our Item is an ItemType.

  // @flow

  import { Record, List } from "immutable";
  import ItemType from "./ItemType";

  export default class Model
    extends Record({
      items: List()
    }) {
    items: List<ItemType>;
  }

Our ItemType has id, name and order.

  // @flow

  import { Record } from "immutable";

  export default class ItemType
    extends Record({
      id: 0,
      name: "",
      order: 0
    }) {
    id: number;
    name: string;
    order: number;
  }

Actions

We have a file for gathering all our actions:

  import DragDropActions from "./DragDropActions";

  export { DragDropActions };

Let's see our DragDropActions:

  function reorderItem(itemId: number, order: number) {
    return {
      type: "REORDER_ITEM",
      payload: {
        itemId,
        order
      }
    };
  }

  function addItem() {
    return {
      type: "ADD_ITEM"
    };
  }

  export default {
    reorderItem,
    addItem
  };

We have two actions: reorderItem and addItem.

Reducer

We have a file importing all our reducers src/reducers/index.js:

  // @flow

  import DragDropReducer from "./DragDropReducer";

  export { DragDropReducer };

Let's see our DragDropReducer:

  // @flow

  import { Model, ItemType } from "../types";
  import { List } from "immutable";
  const init = new Model();

  type ActionType = "ADD_ITEM" | "REORDER_ITEM";

  export default function DragDropReducer(
    model: Model = init,
    action: { type: ActionType, payload: ?Object }
  ) {
    switch (action.type) {
      case "ADD_ITEM":
        return addItem(model, action.payload);
      case "REORDER_ITEM":
        return reorderItem(model, action.payload);
      default:
        return model;
    }
  }

  function addItem(model, payload) {
    return model.updateIn(["items"], items => {
      const maxOrder = model.items.map(i => i.order).max() || 0;
      const itemNumber = Math.floor(Math.random() * 1000) + 1000;
      return items.push(
        new ItemType({
          id: itemNumber,
          name: `Item - ${itemNumber}`,
          order: maxOrder + 1
        })
      );
    });
  }

  function reorderItem(model, payload) {
    if (payload) {
      const { itemId, order } = payload;
      const items = model.items;
      const maxOrder = model.items.map(i => i.order).sort().max();
      // Can't reorder something before the '1' index because we start there
      if (order < 1) {
        return model;
      }
      if (items) {
        const nextOrder = order;
        const nextItems = items.map((value, index) => {
          if (value.id === itemId) {
            if (1 <= nextOrder && nextOrder <= maxOrder) {
              return value.set("order", nextOrder);
            } else {
              return value;
            }
          } else {
            if (value.order >= nextOrder) {
              return value.set("order", value.order + 1);
            } else {
              return value;
            }
          }
        });
        return model.setIn(["items"], nextItems);
      } else {
        console.log("no item", itemId);
        return model;
      }
    } else {
      return model;
    }
  }

In our method addItem we are just adding a new item in our list. The id of the number will be a random number with four digits. The order will be the max order we have in our item list.

In our method reorderItem, we are doing a drag and drop only in one direction, bottom-up.

Connect our component to Redux

We will connect our App component to redux and we will pass the items from our state to props. Our two functions reorderItem and addItem are also being passed to our props.

  import React from "react";
  import "./App.css";
  import { connect } from "react-redux";
  import { DragDropActions } from "./actions";
  import Item from "./Item";
  import { DragDropContext } from "react-dnd";
  import HTML5Backend from "react-dnd-html5-backend";

  const App = props => {
    const { reorderItem } = props;
    const itemsToRender = props.items
      .sortBy(i => i.order)
      .map((item, index) => (
        <Item
          name={item.name}
          id={item.id}
          order={item.order}
          reorderItem={reorderItem}
          key={item.id}
        />
      ));

    return (
      <div className="App">
        {itemsToRender}
        <button onClick={() => props.addItem()}>Add</button>
      </div>
    );
  };

  export const AppContainer = connect(
    function mapStateToProps(state) {
      console.log("state", state);
      return {
        items: state.get("items")
      };
    },
    function mapDispatchToProps(dispatch) {
      return {
        reorderItem: (itemId, order) =>
          dispatch(DragDropActions.reorderItem(itemId, order)),
        addItem: () => dispatch(DragDropActions.addItem())
      };
    }
  )(App);

We show the items sorted by order, by using the method sortBy. To render our Item we have an Item component. Let's see this.

  import React from "react";

  const Item = props => {
    const {
      name
    } = props;
    return (<div className='box'>
          <i className="fa fa-bars" aria-hidden="true" />
          {name}
        </div>
    );
  };

  export default Item;

Our Item component is really simple. We are just showing the item name on it.

This implementation make a list of items and we can add new items, but it's not draggable. Let's see how we can add drag and drop to our list.

Adding Drag and Drop

Now that we already have our redux actions working, let's add drag and drop. To add drag and drop, we will be using React DnD and react-dnd-html5 that uses the HTML5 drag and drop API in its implementation.

  yarn add react-dnd-html5-backend react-dnd

We need to have some types that are draggable - DragTypes. In our case, we only have Item.

  export const DragTypes = {
    ITEM: "item"
  };

Let’s make our item component draggable.

  import React from "react";
  import { DragTypes } from "./DragTypes";
  import { DragSource, DropTarget } from "react-dnd";

  const itemSource = {
    beginDrag(props) {
      return {
        id: props.id
      };
    }
  };

  const itemTarget = {
    canDrop(props, monitor) {
      return true;
    },

    drop(props, monitor) {
      let { reorderItem, order } = props;
      let monitorItem = monitor.getItem();
      reorderItem(monitorItem.id, order);
    }
  };

  function dropCollect(connect, monitor) {
    return {
      connectDropTarget: connect.dropTarget(),
      isOver: monitor.isOver(),
      canDrop: monitor.canDrop()
    };
  }

  function dragCollect(connect, monitor) {
    return {
      connectDragSource: connect.dragSource(),
      connectDragPreview: connect.dragPreview(),
      isDragging: monitor.isDragging()
    };
  }

  const Item = props => {
    const {
      name,
      connectDropTarget,
      connectDragPreview,
      connectDragSource,
      isOver
    } = props;

    let className = "";

    if (isOver) {
      className += " -is-over";
    }

    return connectDropTarget(
      connectDragPreview(
        <div className={`box ${className}`}>
          {connectDragSource(<i className="fa fa-bars" aria-hidden="true" />)}
          {name}
        </div>
      )
    );
  };

  export default DropTarget(DragTypes.ITEM, itemTarget, dropCollect)(
    DragSource(DragTypes.ITEM, itemSource, dragCollect)(Item)
  );

Our drag will be the grippy bars icon, so we need to pass this element to connectDragSource.

  {connectDragSource(<i className="fa fa-bars" aria-hidden="true" />)}

After we start dragging the item, this causes our CSS to change. This is really useful.

Our target will be our item component, let's wrap this with connectDropTarget and connectDragPreview.

To enable these methods in our component as props, we need to wrap our Item component into a DropTarget.

  export default DropTarget(DragTypes.ITEM, itemTarget, dropCollect)(
    DragSource(DragTypes.ITEM, itemSource, dragCollect)(Item)
  );

We need to implement some methods. Some methods related to our item source and target, the two items we are going to change the order.

In our drop we method, once we get the order and the item id, we call the redux action reorderItem.

  const itemSource = {
    beginDrag(props) {
      return {
        id: props.id
      };
    }
  };

  const itemTarget = {
    canDrop(props, monitor) {
      return true;
    },

    drop(props, monitor) {
      let { reorderItem, order } = props;
      let monitorItem = monitor.getItem();
      reorderItem(monitorItem.id, order);
    }
  };

  function dropCollect(connect, monitor) {
    return {
      connectDropTarget: connect.dropTarget(),
      isOver: monitor.isOver(),
      canDrop: monitor.canDrop()
    };
  }

  function dragCollect(connect, monitor) {
    return {
      connectDragSource: connect.dragSource(),
      connectDragPreview: connect.dragPreview(),
      isDragging: monitor.isDragging()
    };
  }

In our component that contains our Item, we need to have the DragDropContext/HTML5Backend.

  export const AppContainer = connect(
    function mapStateToProps(state) {
      console.log("state", state);
      return {
        items: state.get("items")
      };
    },
    function mapDispatchToProps(dispatch) {
      return {
        reorderItem: (itemId, order) =>
          dispatch(DragDropActions.reorderItem(itemId, order)),
        addItem: () => dispatch(DragDropActions.addItem())
      };
    }
  )(App);

  export default DragDropContext(HTML5Backend)(AppContainer);

This should give us the final result:

Redux helps us handle our actions, allowing us to add and reorder our items. To implement the Drag and Drop, we just needed to follow the instructions from docs and it worked out.

Checkout the code in our GitHub.


33b32be65cd662922a71dc1d051ddb9f?s=184&d=mm

Franzé Jr Software Engineer with experience working in multi-cultural teams, Franze wants to help people when he can, and he is passionate about programming and Computer Science. Founder of RemoteMeetup.com where he can meet people all over the World. When Franze is not coding, he is studying something about programming.