Quantcast
Channel: Developer Express Inc.
Viewing all articles
Browse latest Browse all 2370

DevExtreme React Grid - Remote Data Loading Plugin

$
0
0

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.

At the same time, this post is part of a series describing a demo project that employs various real-world patterns and tools to provide access to data in a MongoDB database for the DevExtreme grid widgets. You can find the introduction and overview to the post series by following this link.

The sample described in this blog post loads data from the data service implemented for my DevExtreme - Real World Patterns post series. The data access pattern has been described previously in this post.

I have created a new branch react-plugin for my real world patterns demo. The basis of this branch is react-frontend, so the structure is similar to that described in this post.

Changes to Redux elements

My new plugin takes care of all the loading functionality. As a result, the related handling that was implemented as Redux actions and sagas in react-frontend is not required anymore.

grid-saga.js only handles the BATCH_SAVE and BATCH_DISCARD actions now. Since this functionality is still external to the Grid, I left these parts untouched.

In grid-reducer.js, I have removed handling of the GRID_DATA_LOADED action. GRID_PAGE_SIZE_CHANGED is also gone, since I integrated the special behavior into the plugin to make sure currentPage is adjusted when pageSize changes.

One new action has been introduced, with this simple handling in the reducer:

case GRID_RELOAD:
  return {
    ...state,
    reloadState: new Date().getTime()
  };

When a GRID_RELOAD action is dispatched, this leads to a change in Grid state by setting the reloadState field to a new value. I`m using a numeric value for this, but technically it could be anything. The changed state value is used to indicate to the data loading plugin that a reload is required.

I made changes to the mapDispatchToProps functions for both the grid and the toolbar, to accommodate the changes to the Redux actions.

Finally, I removed those elements from initial grid state that are not needed anymore (rows, totalCount) and added the reloadState instead.

The DevExtremeDataServer plugin

In the render method of the plugin, you can see the building blocks I demonstrated in the post about plugin writing basics. I’ll explain each element in turn.

Rendered plugin elements

The first Watcher extracts all fields from Grid state that are relevant for data loading:

<Watcher
  watch={getter =>
    ['sorting','currentPage','pageSize','filters','grouping','expandedGroups'
    ].map(getter)}
  ...

When any of these values change, the current Grid state is captured in plugin state. A conversion takes place for the expandedGroups because the Grid state keeps the data in a Map instead of an array. I’m also setting the field loading to true, which you will see in use further down to display a loading indicator.

Note that I’m using a rest parameter in the declaration of the onChange delegate. This means I don’t have to repeat the names of the parameters that are already showing right above this code in the watch delegate. The downside is that I need to refer to the values as vals[X] within the body of the delegate.

onChange={(action, ...vals) => {
  ...
  this.setState({
    sorting: vals[0],
    currentPage: newPage,
    pageSize: vals[2],
    filters: vals[3],
    grouping: vals[4],
    expandedGroups: vals[5] ? Array.from(vals[5].values()) : [],
    loading: true
  });

For the correct currentPage value, a calculation is run if the pageSize has changed.

const newPage = this.state.pageSize >= 0 &&
  this.state.pageSize !== vals[2]
  ? Math.trunc(vals[1] * this.state.pageSize / vals[2])
  : vals[1];
  ...

At the end of the onChange delegate, you see the first instance where an action is used to trigger functionality made available by a different plugin. The action is called setCurrentPage and it is exported by the PagingState plugin. Using this approach, my plugin can influence state in other elements from a Watcher.

if (newPage !== vals[1])
  action('setCurrentPage')({
    page: newPage
  });

Moving on, you find three simple Getter elements. These make information from plugin state available to the Grid, by the names of totalCount, rows and loading. Please don’t be confused at this point by the fact that on an initial render run, the fields totalCount and rows don’t contain any useful information! You will see a bit later where these details come from.

<Getter name="totalCount" value={this.getTotalCount()} /><Getter name="rows" value={this.getRows()} /><Getter name="loading" value={this.state.loading} />

Note that the property value is used on all three Getter elements, not the previously discussed pureComputed. This is important because the values are simply retrieved from state. For totalCount and rows, two simple helper methods are used:

getRows() {
  return this.state.loadResult ? this.state.loadResult.rows : [];
}

getTotalCount() {
  return this.state.loadResult ? this.state.loadResult.totalCount : 0;
}

If you tried to use pureComputed in this scenario, it wouldn’t work! The function used for pureComputed is expected to be functionally pure and return new results only if its parameters change. Return values from this function are memoized and the change to the state, which is external to the function, would not be recognized.

The next Getter calculates the correct value for totalPages depending on totalCount and pageSize. The implementation was copied from the PagingState plugin, and conversations with the developers indicate that this may not be required in the future. My comment in the source describes a change I made to the standard logic. Here is the implementation:

<Getter
  name="totalPages"
  pureComputed={(totalCount, pageSize) =>
    pageSize ? Math.ceil(totalCount / pageSize) : 0}
  connectArgs={getter => [getter('totalCount'), getter('pageSize')]}
/>

The final Watcher of the plugin takes care of another edge case scenario. The edge case occurs when totalPages changes and currentPage has a value that is no longer in range given the new totalPages value. If that happens, the setCurrentPage action is executed to “navigate” to the last valid page in range.

<Watcher
  watch={getter => [getter('totalPages'), getter('currentPage')]}
  onChange={(action, totalPages, currentPage) => {
    if (totalPages > 0 && totalPages - 1 <= currentPage)
      action('setCurrentPage')({ page: Math.max(totalPages - 1, 0) });
  }}
/>

The last element of the plugin is a Template. Using the same technique I demonstrated in the plugin writing basics post, this template shows a loading indicator as required:

<Template name="root"><div><TemplatePlaceholder />
    {this.state.loading &&
      this.props.useLoadingIndicator &&
      this.props.loadingIndicator()}</div></Template>

The lifecycle of the plugin

I explained the render method of the plugin first because the elements describe a large part of what the plugin “does”: it waits for changes to certain Grid state values and reacts by changing some of its own state, as well as providing changed Grid state values back through Getter elements. However, this is not the end of the story. I pointed out the two fields totalCount and rows in my description, which are not accounted for so far. And of course I have not triggered the actual data loading process yet, because I’m keeping the render method functionally pure.

The important part of the plugin that I haven’t mentioned yet is its componentDidUpdate method. This is of course a standard React lifecycle method– it is important to keep in mind that plugins are React components!

I’m making two steps in this method, taking care of two different scenarios. First, a component update may happen because the component’s props have changed. I haven’t mentioned any props yet, but in the code just above, for the loading indicator, you can see me using the values useLoadingIndicator and loadingIndicator from props.

For componentDidUpdate, the important field from props is the reloadState that was already mentioned at the start of this post. Comparing the previous props to the current ones, I can see whether this has changed since the previous update, and if it has, I need to set the loading state of the plugin accordingly.

if (this.props.reloadState !== prevProps.reloadState && !this.state.loading)
  this.setState({
    loading: true
  });

The second scenario is that where componentDidUpdate is called as a result of a state change. State only changes when I call setState, and in those cases I need to trigger the data loading process. To be safe, I check individual state fields – it would be possible to optimize this a bit by using a combined state object for all loading-relevant values.

if (
  prevState.sorting !== this.state.sorting ||
  prevState.currentPage !== this.state.currentPage ||
  prevState.pageSize !== this.state.pageSize ||
  prevState.filters !== this.state.filters ||
  prevState.grouping !== this.state.grouping ||
  prevState.expandedGroups !== this.state.expandedGroups ||
  prevProps.reloadState !== this.props.reloadState
)
  this.getData(this.getLoadOptions());

And there, finally, is the getData call that fetches data from the remote server. The implementation is asynchronous and when data has been received, I setState again to incorporate the results:

getData(loadOptions) {
  this.fetchData(loadOptions).then(res => {
    if (res.dataFetched) {
      this.setState({
        reloadState: this.props.reloadState,
        loading: false,
        loadResult: {
          rows: res.data.rows,
          totalCount: res.data.totalCount
        }
      });
    }
  });
}

The last item to mention at this point is the plugin constructor, where I initialize my state. I also create a “data fetcher context” using the connection URL passed through props:

constructor(props) {
  super(props);
  this.state = {
    loadResult: undefined,
    reloadState: undefined,
    loading: false
  };

  ...

  this.fetchData = createDataFetcher(this.props.url);
}

Here is a summary of the data loading process using the plugin:

1 - The plugin is instantiated during the process of rendering the Grid. (The use from the Grid is the final part I haven’t shown yet, see below.) The constructor is called to initialize state.

2 - The plugin render method is executed. The Watcher reacts to the “change” of the values and pulls Grid state into plugin state, also setting loading. Other plugin elements are evaluated, and the Template at the end renders the loading indicator.

3 - The componentDidUpdate method on the plugin is executed. It finds that there are state changes and triggers the data loading process.

4 - When data loading finishes, the loaded data is stored in plugin state and the loading flag is reset. The state change triggers a component update.

5 - On this render run, the Watcher has nothing to react to. The Getter elements push the loaded data into Grid state. The Template doesn’t render the loading indicator anymore.

6 - componentDidUpdate is executed again, but it does nothing.

Using the plugin in the Grid

As you would expect, using the plugin in the Grid is straight-forward and requires only one little block of code:

<DevExtremeDataServer
  url="//localhost:3000/data/v1/values"
  reloadState={reloadState}
  loadingIndicator={
    activeUI === 'material'
      ? () => <MuiLoadingIndicator />
      : undefined // default uses bootstrap styles
  }
/>

There are no surprises here. The URL for the data service is passed in as a property (most other examples in the real world patterns blog series have this URL hard-coded). The reloadState is bound to the Grid state field, so that reloads can be triggered externally through the GRID_RELOAD Redux action explained at the start of this post.

For the loadingIndicator, the plugin has default behavior that renders a Bootstrap compatible indicator. I chose this because Bootstrap UI is the main platform at this time (with Material and others coming in the future), but also because that rendering relies exclusively on CSS styles and could be used without Bootstrap. In the code above, I’m alternatively using the MuiLoadingIndicator (which relies on MuiCircularProgress, a standard component), if Material UI has been selected in the demo application.

Try it!

This concludes the description of the data loading plugin, and also the React Grid blog series, at least for now. I have a number of ideas and plans, so there will probably be updates in the future – feel free to get in touch if you have any questions or ideas of your own!

Meanwhile I recommend playing with the samples I’ve provided throughout the series. One more time, the complete repo for the real world patterns series can be found here, and the various branches on this page. The main README has instructions on running the samples in your own environment, and there are separate instructions on using Cloud 9 online IDE.

Click here to return to the blog series overview


Viewing all articles
Browse latest Browse all 2370

Trending Articles