Stas Bichenko
Stas Bichenko

Reputation: 13273

How can I speed up a React render() method that generates a huge DOM?

The HtmlTable component

Imagine a simple HtmlTable React component. It renders data based on a 2-dimensional array passed to it via data prop, and it can also limit the number of columns and rows via rowCount and colCount props. Also, we need the component to handle huge arrays of data (tens of thousands of rows) without pagination.

class HtmlTable extends React.Component {
    render() {
        var {rowCount, colCount, data} = this.props;
        var rows = this.limitData(data, rowCount);

        return <table>
            <tbody>{rows.map((row, i) => {
                var cols = this.limitData(row, colCount);
                return <tr key={i}>{cols.map((cell, i) => {
                    return <td key={i}>{cell}</td>
                })}</tr>
            })}</tbody>
        </table>
    }

    shouldComponentUpdate() {
        return false;
    }

    limitData(data, limit) {
        return limit ? data.slice(0, limit) : data;
    }
}

The rowHeights props

Now we want to let the user change the row heights and do it dynamically. We add a rowHeights prop, which is a map of row indices to row heights:

{
    1: 100,
    4: 10,
    21: 312
}

We change our render method to add a style prop to <tr> if there's a height specified for its index (and we also use shallowCompare for shouldComponentUpdate):

    render() {
        var {rowCount, colCount, data, rowHeights} = this.props;
        var rows = this.limitData(data, rowCount);

        return <table>
            <tbody>{rows.map((row, i) => {
                var cols = this.limitData(row, colCount);
                var style = rowHeights[i] ? {height: rowHeights[i] + 'px'} : void 0;
                return <tr style={style} key={i}>{cols.map((cell, i) => {
                    return <td key={i}>{cell}</td>
                })}</tr>
            })}</tbody>
        </table>
    }

    shouldComponentUpdate(nextProps, nextState) {
        return shallowCompare(this, nextProps, nextState);
    }

So, if a user passes a rowHeights prop with the value {1: 10}, we need to update only one row -- the second one.

The performance problem

However, in order to do the diff, React would have to rerun the whole render method and recreate tens of thousands of <tr>s. This is extremely slow for large datasets.

I thought about using shouldComponentUpdate, but it wouldn't have helped -- the bottleneck happened before we even tried to update <tr>s. The bottleneck happens during recreation of the whole table in order to do the diff.

Another thing I thought about was caching the render result, and then spliceing changed rows, but it seems to defeat the purpose of using React at all.

Is there a way to not rerun a "large" render function, if I know that only a tiny part of it would change?

Edit: Apparently, caching is the way to go... For example, here's a discussion of a similar problem in React's Github. And React Virtualized seems to be using a cell cache (though I might be missing something).

Edit2: Not really. Storing and reusing the "markup" of the component is still slow. Most of it comes from reconciling the DOM, which is something I should've expected. Well, now I'm totally lost. This is what I did to prepare the "markup":

    componentWillMount() {
        var {rowCount, colCount, data, rowHeights={}} = this.props;
        var rows = this.limitData(data, rowCount);
        this.content = <table>
            <tbody>{rows.map((row, i) => {
                var cols = this.limitData(row, colCount);
                var style = rowHeights[i] ? {height: rowHeights[i] + 'px'} : void 0;
                return <tr style={style} key={i}>{cols.map((cell, i) => {
                    return <td key={i}>{cell}</td>
                })}</tr>
            })}</tbody>
        </table>
    }

    render() {
        return this.content
    }

Upvotes: 25

Views: 10012

Answers (5)

Kyle Finley
Kyle Finley

Reputation: 11992

For this particular case I would recommend react-virtualized or fixed-data-table as mentioned by others. Both components will limit DOM interaction, by lazy loading data, i.e. only render the portion of the table that is visible.

Speaking more generally, the MobX documentation has an excellent page on react performance. Please check it out. Here are the bullet points.

1. Use many small components

mobx-react's @observer components will track all values they use and re-render if any of them changes. So the smaller your components are, the smaller the change they have to re-render; it means that more parts of your user interface have the possibility to render independently of each other.

observer allows components to render independently from their parent

2. Render lists in dedicated components

This is especially true when rendering big collections. React is notoriously bad at rendering large collections as the reconciler has to evaluate the components produced by a collection on each collection change. It is therefore recommended to have components that just map over a collection and render it, and render nothing else:

3. Don't use array indexes as keys

Don't use array indexes or any value that might change in the future as key. Generate id's for your objects if needed. See also this blog.

4. Dereference values lately

When using mobx-react it is recommended to dereference values as late as possible. This is because MobX will re-render components that dereference observable values automatically. If this happens deeper in your component tree, less components have to re-render.

5. Bind functions early

This tip applies to React in general and libraries using PureRenderMixin especially, try to avoid creating new closures in render methods.

Example (untested)

import { observer } from 'mobx-react';
import { observable } from 'mobx';


const HtmlTable = observer(({
  data,
}) => {
  return (
    <table>
      <TBody rows={data} />
    </table>
  );
}

const TBody = observer(({
  rows,
}) => {
  return (
    <tbody>
      {rows.map((row, i) => <Row row={row} />)}
    </tbody>
  );
});

const Row = observer(({
  row,
}) => {
  return (
    <tr key={row.id} style={{height: row.rowHeight + 'px'}}>
      {row.cols.map((cell, i) => 
        <td key={cell.id}>{cell.value}</td>
      )}
    </tr>
  );
});

class DataModel {
  @observable rows = [
    { id: 1, rowHeight: 10, cols: [{ id: 1, value: 'one-1' }, { id: 2, value: 'one-2' }] },
    { id: 2, rowHeight: 5,  cols: [{ id: 1, value: 'two-1' }, { id: 2, value: 'two-2' }] },
    { id: 3, rowHeight: 10,  cols: [{ id: 1, value: 'three-1' }, { id: 2, value: 'three-2' }] },
  ];
  @observable rowLimit = 10;
  @observable colLimit = 10;

  @computed
  get limitedRows() {
    return this.limitData(this.rows, this.rowLimit).map(r => {
      return { id: r.id, cols: this.limitData(r.col, this.colLimit) };
    });
  }

  limitData(data, limit) {
    return limit ? data.slice(0, limit) : data;
  }
}

const data = new DataModel();

React.render(<HtmlTable data={data.limitedRows} />, document.body);

sources:

Upvotes: 23

webdeb
webdeb

Reputation: 13211

The problem here is to change the inline styles.. maybe just use the styles for the particular <tr> outside of the table..

  1. Generate the table, add classNames or ids to the <tr>
  2. Generate the stylesheets and inject them into the <head>

... repeat the 2. step without changing the table

(just generate the css text with js)

.tr-0, #tr-0, .tr-1, ... { height: 10px }
...

I did't tested it, its just an idea, but I think this could be a way to go, if you don't want to touch the generated table after rendering..

You can use some really simple js to generate <style> and put them into the head:

var style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = '.tr-0 { height: 10px; }';
// write some generator for the styles you have to change and add new style tags to the head...
document.getElementsByTagName('head')[0].appendChild(style);

Upvotes: 2

Shishir Arora
Shishir Arora

Reputation: 5923

I know this problem is not that simple. But if its only related to style then keep styles of rows also in parent-table tag.

https://codepen.io/shishirarora3/pen/grddLm

class Application extends React.Component {
  renderStyle=(x,y)=> {
    console.log(x,y);
    let styleCode = `table tr:nth-child(${x}){ height: ${y} }`
    console.log(styleCode);
    return (
        <style>{ styleCode }</style>
    )
};
  render() {
    return <div>
      {this.renderStyle(1,'100px')}
      <table>
        <tr><td>1</td></tr>
        <tr><td>2</td></tr>
        <tr><td>3</td></tr>
      </table>

    </div>;
  }
}

/*
 * Render the above component into the div#app
 */
React.render(<Application />, document.getElementById('app'));

Upvotes: 1

Filip Dupanović
Filip Dupanović

Reputation: 33670

You should investigate on how to perform reconciliation within a worker (or a pool thereof) and transfer the result back to your main thread. There exists a project that attempts to do just that, so you can experiment with their implementation.

Please note that I belong to the majority that has never encountered such requirements with React as yourself, so I can't provide any implementation details or further hints, but I believe web-perf/react-worker-dom may be a good place to start to start exploring and discovering similar solutions.

Upvotes: 2

Piyush.kapoor
Piyush.kapoor

Reputation: 6803

Make each row as a subcomponent and pass the props to that subcomponent. That subcomponent will have its own state and can change without affecting all the rows

class HtmlTable extends React.Component {
   render() {
       var {rowCount, colCount, data} = this.props;
       var rows = this.limitData(data, rowCount);

       return <table>
           <tbody>{rows.map((row, i) => {
               var cols = this.limitData(row, colCount);

               return (<Row cols={cols} key={i}/>)
           })}</tbody>
       </table>
   }

   shouldComponentUpdate() {
       return false;
   }

   limitData(data, limit) {
       return limit ? data.slice(0, limit) : data;
   }
}

Upvotes: 2

Related Questions