Flexible layouts with React DnD

Javascript tools have seen a lot of improvements lately. Taking advantage of the right tools, we can do a lot of complicated things with a fairly small amount of effort.

The best way to illustrate this is by example. We're going to use React, Redux and React DnD in order to make an HTML table that we can reorder by dragging and dropping columns.

The first step is to take a look at our data. We're keeping things simple and just using an object with a table key that will populate our table's data.

const initialState = {  
    table: {
        headings: [
            {
                name: 'Column 1'
            },
            {
                name: 'Column 2'
            },
            {
                name: 'Column 3'
            }
        ],
        rows: [
            {
                name: '15',
                column: 'Column 1'
            },
            {
                name: '25',
                column: 'Column 2'
            },
            {
                name: '35',
                column: 'Column 3'
            }
        ]
    }
}

We have an array for the headings and an array for the rows with a name and a column that they belong in.

Now we know what we're working with and we can wire up a static table to display.

If you want to run the app and follow along in the code, you can clone this repo. It uses webpack to serve it up.

React

We need 3 components to represent our data. Table.js, Row.js, and Column.js. We'll also use App.jsx to render the other 3 components.

Our row component is really simple, it just take a row object and displays it's name property.

import React from 'react'

const Row = ({row}) => (<td>{row.name}</td>)

export default Row  

Since React 0.14, we can write components with props destructuring and an implied return which keeps them nice and short.

Our column component will start out the same way, but will eventually be our drag-and-drop component.

import React from 'react'

const Column = ({column}) => (<td>{column.name}</td>)

export default Column  

Now we'll make a Table component where we can pull in our rows and columns and then render Table from App.jsx which will be our root component.

import React from 'react'  
import Column from '../components/Column'  
import Row from '../components/Row'

const Table = ({ table, actions }) => (  
    <table>
        <thead>
            <tr>
                { table.headings.map((column) => <Column key={column.name} column={column} drag={actions.drag} />) }
            </tr>
        </thead>
        <tbody>
            <tr>
                {
                    table.rows.map((row) => <Row key={row.name} row={row} />)
                }
            </tr>
        </tbody>
    </table>
)

export default (Table)  

We'll get our table and actions props from our data layer in the next section.

import './index.html'  
import 'babel-core/polyfill'  
import Table from './containers/Table'



import React from 'react'  
import ReactDOM from 'react-dom'

const App = () => (  
    <div>
      <Table />
    </div>
)

ReactDOM.render(<App />, document.getElementById('app'))

With a handful of simple components, we have everything we need in order to render the data we looked at before. So let's do that.

Redux

Redux gives us a simple API for a predictable state container, we're going to wire up our root component to a redux store which will be updated by one reducer function in our case. While we're in there, we're also going to take advantage of the redux-devtools package to get a side panel with our apps state on the screen and state rewinding.

Let's start by writing a constant for our action type. We should only need one action to demo this, the drag action.

export const DRAG = 'drag'  

We'll use that constant in our drag action in order to tell our reducer what we're changing.

import * as types from '../constants/constants'

export const drag = (draggedCol, targetCol) => {  
    return { type: types.DRAG, draggedCol, targetCol }
}

Here we're expecting our drag action to be supplied with the column we're dragging and the column we're targeting and we're returning an object that describes the action.
Our reducer needs to consume that action type and give us back a new state for our UI.

import * as types from '../constants/constants'

const initialState = {  
    table: {
        headings: [
            {
                name: 'Column 1'
            },
            {
                name: 'Column 2'
            },
            {
                name: 'Column 3'
            }
        ],
        rows: [
            {
                name: '15',
                column: 'Column 1'
            },
            {
                name: '25',
                column: 'Column 2'
            },
            {
                name: '35',
                column: 'Column 3'
            }
        ]
    }
}

export default function drag(state = initialState, action) {  
    switch (action.type) {
        case types.DRAG:
            return reOrderCols(state, action.draggedCol, action.targetCol)
        default:
            return state
    }
}

const reOrderCols = (state, draggedCol, targetCol) => {  
    let colOrder = state.table.headings.map((heading) => heading.name)
    let columns = state.table.headings.slice()
    let draggedColIndex = colOrder.indexOf(draggedCol.name)
    let targetColIndex = colOrder.indexOf(targetCol.name)

    columns.splice(draggedColIndex, 1)
    columns.splice(targetColIndex, 0, draggedCol)

    let rowOrder = columns.map((col) => {
        return state.table.rows.filter((row) => {
            if (col.name === row.column) return row
        })[0]
    })

    return { table: { headings: columns, rows: rowOrder } }
}

This is where the logic happens for our app. We're importing the same constant that our action uses to identify the type of action and return a state. We define an initial state for our app and we use it as a default parameter for our state. The reOrderCols function finds the index for our dragged and targeted columns and swaps out their order accordingly. Then, we compare this new order of columns in order to reorder our rows to match and return the new state.

Now we need include our actions in our Table component which will pass the drag action into the Column component as we have already written. We need to make a few more updates to the Table component in order to communicate with our data layer. We'll bind our actions and state from redux using the connect function from the react-redux package.

import React from 'react'  
import { bindActionCreators } from 'redux'  
import { connect } from 'react-redux'  
import Column from '../components/Column'  
import Row from '../components/Row'  
import * as DragActions from '../actions/actions'

const Table = ({ table, actions }) => (  
    <table>
        <thead>
            <tr>
                { table.headings.map((column) => <Column key={column.name} column={column} drag={actions.drag} />) }
            </tr>
        </thead>
        <tbody>
            <tr>
                {
                    table.rows.map((row) => <Row key={row.name} row={row} />)
                }
            </tr>
        </tbody>
    </table>
)

function mapStateToProps(state) {  
  return {
    table: state.table
  }
}

function mapDispatchToProps(dispatch) {  
  return {
    actions: bindActionCreators(DragActions, dispatch)
  }
}

export default connect(  
  mapStateToProps,
  mapDispatchToProps
)(Table))

Our table can now be reordered with two pieces of information, a column that is being moved and a target location to move it to. So to test this out, we'll bring in React DnD. This library gives us the glue we need to use our API.

React DnD

We don't have to do much in order to get this working. We need to bring in react-dnd and react-dnd-html5-backend for our Table component and wrap our component with the DragDropContext. So our export now looks like this.

export default connect(  
  mapStateToProps,
  mapDispatchToProps
)(DragDropContext(HTML5Backend)(Table))

And finally we need to connect the DragSource and DropTarget, both in the Column component.

import React, { Component as C} from 'react'  
import { DragSource, DropTarget } from 'react-dnd'  
import { pipe } from 'ramda'

const headingSource = {  
    beginDrag(props) {
        return {
            name: props.column.name
        }
    }
}

const headingTarget = {  
    drop(props, monitor, component) {
        let draggedCol = monitor.getItem()
        let targetCol = component.props.column
        // trigger drag action
        props.drag(draggedCol, targetCol)
    }
}

function collect(connect, monitor) {  
    return {
        connectDragSource: connect.dragSource(),
        isDragging: monitor.isDragging()
    }
}

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

const Column = ({ column, connectDropTarget, connectDragSource, isOver, isDragging }) => (  
    <th style={{
            opacity: isOver ? 0.5 : 1,
            backgroundColor: isOver ? 'yellow' : 'inherit'
        }}>
        {
            connectDropTarget(connectDragSource(
                <div>
                    {column.name}
                </div>
            ))
        }
    </th>
)

export default pipe(DragSource('column', headingSource, collect), DropTarget('column', headingTarget, collectDrop))(Column)

We write function for our source and target to get the information we need and then call the drag action that is passed in to our props from redux with the necessary information. We then also get some props passed into our component that help us connect the drag sources and targets. In our case, they are the same element so we can take our {column.name} and wrap it in a div to avoid seeing borders when we drag it around and the wrap that div with both functions.

We also use a few boolean flags in provided in order to style elements in transition.

The last part needed is to wrap our export with the DragSource and DropTarget functions after we give them their helper functions we wrote earlier. We pull in ramda to make this easier by using the pipe to compose these functions and our Component. With a few presentational styles we have a working drag and drop sortable table. If you want to run the demo, you can find it here and I'm @stides303 on twitter if you have any questions.

Thanks!