DevExtreme React Grid - Reduxified

Oliver's Blog
06 July 2017

This post is part of a series about the DevExtreme React Grid. You can find the introduction and overview to the post series by following this link.

Redux implements a variation of the Flux pattern. In simple terms, it provides a pattern and the tools to handle application state in a predictable and consistent way. If you are not familiar with Redux and the ideas behind it, I recommend reading this very short introduction.

Using Redux in conjunction with the React Grid means to keep state information of the Grid and its various plugins in the Redux store. My sample for this post (as usual, at the bottom) stores all such state information in the Redux store, but it is common enough in real-world applications to make exceptions to this rule. One of the three principles of Redux is to view the store as the single source of truth, but you should keep in mind that the “truth” in question is really only the set of information that is of some meaning to other parts of your application. As such, it is quite legitimate to identify certain parts of a component’s state that should be viewed as internal, and should not be made available to the rest of your code.

Right at the top of my code, I import four elements from the two libraries redux and react-redux. The latter helps interface between the React components I use, and the Redux store.

const { createStore, compose } = Redux;
const { Provider, connect } = ReactRedux;

// As in other posts in this series, if you are working within
// a build environment, you would usually use import statements
// like these:
import { createStore, compose } from 'redux';
import { Provider, connect } from 'react-redux';

Controlling the Grid

Working further down in the code, the next important difference to previous versions of my sample is the code at the start of the render function. I used to simply extract the rows and columns from component state, and now I’m getting hold of altogether 31 items from this.props. I won’t replicate the entire code block here, suffice it to mention elements like rows and columns (no surprise there), but also sorting, grouping and selection as well as event handlers like onFiltersChange or commitChanges.

  render() {
    const {
      rows,
      columns,
      sorting,
      ...
      onFiltersChange,
      ...
      commitChanges
    } = this.props;

I will describe later how this happens, but for now it is important to understand that all the state information needed by the Grid and its plugins to do their work now come “from the outside” through the component props. As I mentioned in one of the earlier posts of the series, this is called controlled mode.

The JSX code that renders all the state plugins has changed in this sample, to configure the plugins with the information I extracted from the props:

<SortingState sorting={sorting} onSortingChange={onSortingChange} />
<PagingState
  pageSize={pageSize}
  onPageSizeChange={onPageSizeChange}
  currentPage={currentPage}
  onCurrentPageChange={onCurrentPageChange}
  totalCount={totalCount}
  />
<FilteringState filters={filters} onFiltersChange={onFiltersChange} />
...

I’m calling the class ReduxGrid now. Technically the grid is not tied to Redux in any way – it simply expects to be configured through props, wherever these props come from.

Actions and reducers

Redux expects each modification to the store to be modeled by an action. I create helper functions, so-called action creators, to generate the objects that represent actions. I have a simple action to handle arbitrary state changes:

const GRID_STATE_CHANGE = "GRID_STATE_CHANGE";

const gridStateChange = (stateFieldName, stateFieldValue) => ({
  type: GRID_STATE_CHANGE,
  stateFieldName,
  stateFieldValue
});

I have special actions for two cases. The first is a change to the pageSize, because I’m going to implement some logic to change currentPage when pageSize changes. Second, there is a gridSave action that will be triggered when the user makes changes to data and saves them.

const GRID_PAGE_SIZE_CHANGE = "GRID_PAGE_SIZE_CHANGE";
const GRID_SAVE = "GRID_SAVE";

const gridPageSizeChange = pageSize => ({
  type: GRID_PAGE_SIZE_CHANGE,
  pageSize
});

const gridSave = ({ added, changed, deleted }) => ({
  type: GRID_SAVE,
  added,
  changed,
  deleted
});

You can define actions freely and the pattern I’m using to assign an action type is not the only possible one. Of course the more actions you have, the more complex their handling becomes.

With the actions defined, I also need a reducer to handle them. Reducers are building blocks in Redux that receive the current state and an action to handle, and they are expected to return the resulting state. Redux favors functional purity, so you should regard the state as immutable. In my sample code, I am using standard JavaScript data types (instead of a library like Immutable.js), so it is up to me to make sure that data is not changed in place.

Here is my reducer with a few parts cut out for now:

function createGridReducer(initialState) {
  return (state = initialState, action) => {
    switch (action.type) {
      case GRID_STATE_CHANGE:
        return {
          ...state,
          [action.stateFieldName]: action.stateFieldValue
        };

      // ... some actions missing here for now

      default:
        return state;
    }
  };
}

The reducer is actually only the part returned by the return statement of the outer function createGridReducer. This pattern allows me to create my reducer by passing in an initialState, instead of hard-coding that initial state. The reducer itself, as promised, accepts the current state and an action as parameters. It looks at the action type and, if the action should have a type that isn’t recognized by this reducer, returns the state directly.

If the action is recognized by the reducer, a new state object is created and returned. The spread operator (...) makes it easy to include all the old state in the new object, and the change encapsulated by the action overrides the relevant part in the new state.

If the pageSize changes, I consider also changing the currentPage:

case GRID_PAGE_SIZE_CHANGE:
  const newPage = Math.trunc(
    state.currentPage * state.pageSize / action.pageSize
  );
  return {
    ...state,
    currentPage: newPage,
    pageSize: action.pageSize
  };

The state returned by a reducer can differ from the original state in any arbitrary way. The caveat is that if you have lots of actions that influence the same state fields, it might become harder to understand state changes in a running application.

The final action handled by this reducer is GRID_SAVE. The code to handle it is actually short:

case GRID_SAVE:
  return _.flow(
    Array.from(
      commitChanges(action.added, action.changed, action.deleted)
    )
  )(state);

commitChanges is a generator function that returns a sequence of other function calls. These individual function calls each handle one data change, a new row, a changed row or a deleted row. Since each of these calls is written to accept a state object and return a new state object, I can use the lodash flow helper (which is similar to compose in functional programming languages) to execute the complete sequence of functions and return a final state. Further details of this technique are outside the scope of this article, but feel free to ask about it and I’ll provide some more explanations.

Note that the implementation of data persistence has been done for purposes of this demo. Since data modifications are interactive, I don’t need to expect vast numbers of them happening at the same time. (In fact, at this point of the demo there will only ever be one change at a time. I will implement batch saving in the next upcoming post of the series.) As a result, there won’t be any performance problems due to the fact that each individual change function creates a new state object. However, in some real-world scenarios, large numbers of changes would break performance with this approach. I recommend looking into batch mutation support in Immutable.js for such cases.

Connecting the grid

At this point, I have implemented my action handling, and a Grid variation that expects to receive all its configuration details through its props. I have not created the Redux store yet, but I will soon - and then the question is how the information from the store finds its way to the Grid. The react-redux library provides helpers for this purpose, which are explained in detail on this page.

I will take advantage of a helper function called connect. This function creates a wrapper for a React component, for instance my ReduxGrid. However, it can’t do everything itself and I need to supply two helper functions to connect.

The first helper is usually called mapStateToProps. You can guess from the name what this function is supposed to do: it takes the complete state from the Redux store and returns the subset required by the connected component, which will be passed to the component as props. This is important for applications more complex than this step of the sample, because the store will have lots of information that apply to other parts of the application. In this sample, the store only has grid information, so my implementation is this:

const mapGridStateToProps = state => state;

The second helper has the common name mapDispatchToProps. We haven’t heard of this dispatch thing yet that apparently needs mapping. It is a function that allows us to dispatch actions to the Redux store. Logically, this is required in component event handlers – when the user interacts with the component, events are triggered and actions need to be dispatched. The mapDispatchToProps function receives the dispatch function as a parameter and it is expected to return an object that will be merged with the props returned by mapStateToProps before it goes to the component.

For my implementation, I need to create all the event handlers you have already seen at the start of the render function: onFiltersChange, onSelectionChange and many others like those, and also commitChanges. Here is my code, shortened a bit:

const mapGridDispatchToProps = dispatch => {
  const stateChangeEventHandlers = [
    { event: "onSortingChange", field: "sorting" },
    { event: "onCurrentPageChange", field: "currentPage" },
    { event: "onFiltersChange", field: "filters" },
    // ... other events that use gridStateChange 
  ].reduce((r, v) => {
    r[v.event] = val => dispatch(gridStateChange(v.field, val));
    return r;
  }, {});
  return {
    ...stateChangeEventHandlers,
    onPageSizeChange: pageSize => dispatch(gridPageSizeChange(pageSize)),
    commitChanges: changes => dispatch(gridSave(changes))
  };
};

Many of the grid events need handlers that just do this:

dispatch(gridStateChange('FIELD', VALUE));

Using the array at the start of the function makes it a bit easier to maintain these events, which are all generated by the reduce call. Before I return the resulting object, I add specific handlers that trigger my two “special” actions using the creators gridPageSizeChange and gridSave. Of course you are free to add all the simple change event handlers to the return structure directly, in case you find the reduce approach too complicated.

Now for real… connecting the grid to the store

With the helpers sorted out, I can finally call the connect helper:

const ConnectedGrid = connect(mapGridStateToProps, mapGridDispatchToProps)(
  ReduxGrid
);

In order to create the store, I need to call into my createGridReducer function and pass the initial state. This includes my demo data as well as all the grid options (shortened a bit for brevity):

const gridReducer = createGridReducer({
  columns: [
  ...
  ],
  rows: [
  ...
  ],
  sorting: [{ columnName: "name", direction: "asc" }],
  currentPage: 0,
  totalCount: 0,
  pageSize: 10,
  allowedPageSizes: [0, 5, 10, 20],
  filters: [],
  grouping: [],
  expandedGroups: [],
  selection: [],
  expandedRows: [],
  order: ["name", "artist", "year"],
  editingRows: [],
  addedRows: [],
  changedRows: {},
  deletedRows: []
});

The helper createStore that I imported all the way at the top of my code can create a store now, as easily as this: createStore(gridReducer). In the sample, I’m using one more parameter:

const store = createStore(
  gridReducer,
  window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);

The strange part that follows the reducer parameter enables the Redux application to interact with the Redux DevTools Extension, which can be installed in Chrome and allows you valuable insight into the inner workings of Redux in the application.

Finally, there’s one more question that needs answering. The ConnectedGrid has been created by the connect helper, and I was pointing out then that this is meant to make store information available to props. But how does the ConnectedGrid know where the store is? The answer can be found in this final snippet:

ReactDOM.render(
  <div>
    <Provider store={store}>
      <ConnectedGrid />
    </Provider>
  </div>,
  document.getElementById("app")
);

I utilize the Provider component, again imported at the top of the code, and wrap the ConnectedGrid in it. The Provider in turn receives the store reference in its props and “provides” it for all connected components in its scope.

Take a deep breath

Phew.

Right?

Now the whole thing is complete, you can run it. I recommend clicking the Edit on CodePen link in the top right corner of the embedded sample below. Once you are in CodePen, click the Change View button and select Debug Mode. This opens the running sample in a separate page, without any of the CodePen elements around it. Make sure you have the Redux DevTools Extension installed and hit F12. In the Developer Tools window, select Redux from the top menu. Now interact with the grid, and you’ll see the resulting actions popping up in the Redux debugger. Be sure to check out the details of the state changes, and activate the slider at the bottom to enable time-traveling for the actions!

Click here to return to the blog series overview

Free DevExpress Products - Get Your Copy Today

The following free DevExpress product offers remain available. Should you have any questions about the free offers below, please submit a ticket via the DevExpress Support Center at your convenience. We'll be happy to follow-up.
No Comments

Please login or register to post comments.