Rendering Large Datasets With AngularJS And ReactJS
There's been a lot of talk lately about the performance benefits of ReactJS. Both externally and internally on my own engineering team. This post doesn't really offer up anything new; but, as a diehard AngularJS fan, I wanted to see, feel, and experience the difference in performance for myself. Plus, I am trying to learn more about ReactJS, so these side-by-side comparisons help me figure out how to translate my AngularJS thinking into a ReactJS context.
Run this demo in my JavaScript Demos project on GitHub.
Typically, when I hear people talk about the benefits of ReactJS, it's often in the context of the initial load of data. But, that's only one aspect of the user experience (UX). As such, in this little exploration, I'm trying to cover a move diverse set of interactions:
- Initial load of page.
- Interaction with data.
- Unmounting of data.
- Remounting of data.
And, I'm doing this with a rather large dataset - 10,000 data points (well, technically 11,000, but I'm really only considering the things being filtered). If you go higher in size, any differences may become more pronounced; but, I'm trying to keep this somewhere in the realm of reality.
Before I show the code, let's just talk about expectations and results. As a huge AngularJS fan, I really really really wanted AngularJS to outperform ReactJS. But, the reality is - in my experience here - ReactJS is faster. The performance difference is larger on the loading of the entire dataset. But, even when we localize inspection to interactions with the rendered data, ReactJS is still a bit faster.
Now, don't get me wrong, both frameworks are really fast. And, when you reduce the size of the dataset down to something a bit more realistic (for most use-cases), I can't perceive much difference in performance between the two frameworks. But, I can't deny that ReactJS does seem to do a better job of rendering a very large amount of data.
As far as measuring the difference in performance, I'm relying primarily on my perception as a user and then, secondarily, on the Scripting time reported by the Chrome dev tools. I'll leave it up to the smarter engineers to come up with the Sciencey stuff - I fear that if I try to report any "numbers", they will be more misleading than anything else.
To explore performance, I am creating a data grid that has 11,000 cells, 10,000 of which can be filtered based on user-input (1,000 of them are just row headers). As the user types into the filter-form, all the cells remain visible, but the matching cells are highlighted in yellow.
All of this can be seen vividly in the Video above.
First, let's take a look at the AngularJS example. Since the experiment is relatively small in scope, I am keeping the AngularJS example as a single Controller and View. For me, this makes the demo easier to reason about and to understand.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Rendering Large Datasets With AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as vm">
<h1>
Rendering Large Datasets With AngularJS
</h1>
<form>
<strong>Filter Data</strong>:
<input type="text" ng-model="vm.form.filter" />
<!--
If the user is filtering the data, we want to offer some insight into
the breadth of the filtering.
-->
<span ng-if="vm.form.filter">
—
Filtering <strong>{{ vm.form.filter }}</strong>
over {{ vm.dataPoints }} data points,
{{ vm.visibleCount }} found.
</span>
<!-- Provide tooling to unmount and remount the grid. -->
<a ng-if="vm.grid.length" ng-click="vm.unmountGrid()">Unmount Grid</a>
<a ng-if="! vm.grid.length" ng-click="vm.remountGrid()">Remount Grid</a>
</form>
<table width="100%" cellspacing="2" ng-class="{ filtered: vm.form.filter }">
<tr ng-repeat="row in vm.grid track by row.id">
<td>
{{ row.id }}
</td>
<td
ng-repeat="item in row.items track by item.id"
class="item"
ng-class="{ hidden: item.isHiddenByFilter }">
{{ item.value }}
</td>
</tr>
</table>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.2.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
angular.module( "Demo" ).controller(
"AppController",
function provideAppController( $scope ) {
var vm = this;
// We'll start out with a grid with 10,000 items.
vm.grid = generateGrid( 1000, 10 );
// Calculate the number of data-points that may have filtering.
vm.dataPoints = ( vm.grid.length * vm.grid[ 0 ].items.length );
// I hold the form data for use with ngModel.
vm.form = {
filter: ""
};
// I hold the number of items that are visible based on filtering.
vm.visibleCount = 0;
// As the user interacts with filter, we need to update the view-model
// to reflect the matching items.
$scope.$watch( "vm.form.filter", handleFilterChange );
// Expose the public API.
vm.remountGrid = remountGrid;
vm.unmountGrid = unmountGrid;
// ---
// PUBLIC METHODS.
// ---
// I update the visibility of the items when the filter is updated.
function handleFilterChange( newValue, oldValue ) {
if ( newValue === oldValue ) {
return;
}
// Reset the visible count. As we iterate of the items checking
// for visibility, we can increment this count as necessary.
vm.visibleCount = 0;
for ( var r = 0, rowCount = vm.grid.length ; r < rowCount ; r++ ) {
var row = vm.grid[ r ];
for ( var c = 0, columnCount = row.items.length ; c < columnCount ; c++ ) {
var item = row.items[ c ];
// The item is hidden if the given filter text cannot be
// found in the value of the item.
item.isHiddenByFilter = ( newValue && ( item.value.indexOf( newValue ) === -1 ) );
// If the item isn't hidden, track it as part of the visible
// set of data.
if ( ! item.isHiddenByFilter ) {
vm.visibleCount++;
}
}
}
}
// I repopulate the grid with data. This will help separate processing
// performance characteristics from page-load processing.
function remountGrid() {
vm.grid = generateGrid( 1000, 10 );
vm.dataPoints = ( vm.grid.length * vm.grid[ 0 ].items.length );
vm.visibleCount = 0;
vm.form.filter = "";
}
// I clear the grid of data. This will help separate processing
// performance characteristics from page-load processing.
function unmountGrid() {
vm.grid = [];
vm.dataPoints = 0;
vm.visibleCount = 0;
vm.form.filter = "";
}
// ---
// PRIVATE METHODS.
// ---
// I generate a grid of items with the given dimensions. The grid is
// represented as a two dimensional grid, of sorts. Each row has an
// object that has an items collection.
function generateGrid( rowCount, columnCount ) {
var valuePoints = [
"Daenerys", "Jon", "Sansa", "Arya", "Stannis", "Gregor", "Tyrion",
"Theon", "Joffrey", "Ramsay", "Cersei", "Bran", "Margaery",
"Melisandre", "Daario", "Jamie", "Eddard", "Myrcella", "Robb",
"Jorah", "Petyr", "Tommen", "Sandor", "Oberyn", "Drogo", "Ygritte"
];
var valueIndex = 0;
var grid = [];
for ( var r = 0 ; r < rowCount ; r++ ) {
var row = {
id: r,
items: []
};
for ( var c = 0 ; c < columnCount ; c++ ) {
row.items.push({
id: ( r + "-" + c ),
value: valuePoints[ valueIndex ],
isHiddenByFilter: false
});
if ( ++valueIndex >= valuePoints.length ) {
valueIndex = 0;
}
}
grid.push( row );
}
return( grid );
}
}
);
</script>
</body>
</html>
Ok, now let's look at the ReactJS version. I should caveat that I am very new to ReactJS, so cut me some slack or, even better yet, leave some constructive criticism in the comments. That said, I have tried to follow the practices that I see outlined in the ReactJS tutorials.
In my first attempt at the ReactJS version, I tried to create this as a single Component so that it lines up more closely with the AngularJS version. However, this made the code extremely hard to reason about - tons of IF statements building conditional HTML (JSX). By breaking it up into smaller components, it makes the render() methods a bit more human-consumable. In either case, however, there's quite a bit more code when compared to the AngularJS version.
I am using the inline JSX rendering, which will, of course, have some overhead on the initial rendering of the page. But, once the page is rendered and the JSX is transpiled down into JavaScript, there's should be no difference.
NOTE: I am using the minified version of the ReactJS library. This results in faster performance, when compared to the development version, as it removes all of the performance instrumentation and the user-friendly error messages.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>
Rendering Large Datasets With ReactJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body>
<h1>
Rendering Large Datasets With ReactJS
</h1>
<div id="content">
<!-- This content will be replaced with the React rendering. -->
</div>
<!-- Load scripts. -->
<script src="../../vendor/reactjs/react-0.13.3.min.js"></script>
<script src="../../vendor/reactjs/JSXTransformer-0.13.3.js"></script>
<script type="text/jsx">
// I manage the Demo widget.
var Demo = React.createClass({
// I provide the initial view-model, before the component is mounted.
getInitialState: function() {
// We'll start out with a grid with 10,000 items.
return({
grid: this.generateGrid( 1000, 10 ),
form: {
filter: ""
}
});
},
// ---
// PUBLIC METHODS.
// ---
// I render the view using the current state and properties collections.
render: function() {
if ( this.state.grid.length ) {
var dataPoints = ( this.state.grid.length * this.state.grid[ 0 ].items.length );
} else {
var dataPoints = 0;
}
var visibleCount = this.getVisibleCount();
return(
<div>
<DemoForm
dataPoints={ dataPoints }
visibleCount={ visibleCount }
filter={ this.state.form.filter }
onFilterChange={ this.setFilter }
isMounted={ !! this.state.grid.length }
onUnmount={ this.unmountGrid }
onRemount={ this.remountGrid }
/>
<DemoTable
grid={ this.state.grid }
filter={ this.state.form.filter }
/>
</div>
);
},
// I repopulate the grid with data. This will help separate processing
// performance characteristics from page-load processing.
remountGrid: function() {
this.setState({
grid: this.generateGrid( 1000, 10 ),
form: {
filter: ""
}
});
},
// I update the state for filtering.
setFilter: function( newFilter ) {
// When we update the filter, we don't have to mutate any other state
// since the filtering is actually applied in the render() methods.
this.setState({
form: {
filter: newFilter
}
});
},
// I clear the grid of data. This will help separate processing performance
// characteristics from page-load processing.
unmountGrid: function() {
this.setState({
grid: [],
form: {
filter: ""
}
});
},
// ---
// PRIVATE METHODS.
// ---
// I calculate and return the visible count of items based on the current
// state of the filtering.
getVisibleCount: function() {
var count = 0;
for ( var r = 0, rowCount = this.state.grid.length ; r < rowCount ; r++ ) {
var row = this.state.grid[ r ];
for ( var c = 0, columnCount = row.items.length ; c < columnCount ; c++ ) {
var item = row.items[ c ];
var isHidden = ( this.state.form.filter && ( item.value.indexOf( this.state.form.filter ) === -1 ) );
if ( ! isHidden ) {
count++;
}
}
}
return( count );
},
// I generate a grid of items with the given dimensions. The grid is
// represented as a two dimensional grid, of sorts. Each row has an object
// that has an items collection.
generateGrid: function( rowCount, columnCount ) {
var valuePoints = [
"Daenerys", "Jon", "Sansa", "Arya", "Stannis", "Gregor", "Tyrion",
"Theon", "Joffrey", "Ramsay", "Cersei", "Bran", "Margaery",
"Melisandre", "Daario", "Jamie", "Eddard", "Myrcella", "Robb",
"Jorah", "Petyr", "Tommen", "Sandor", "Oberyn", "Drogo", "Ygritte"
];
var valueIndex = 0;
var grid = [];
for ( var r = 0 ; r < rowCount ; r++ ) {
var row = {
id: r,
items: []
};
for ( var c = 0 ; c < columnCount ; c++ ) {
row.items.push({
id: ( r + "-" + c ),
value: valuePoints[ valueIndex ],
isHiddenByFilter: false
});
if ( ++valueIndex >= valuePoints.length ) {
valueIndex = 0;
}
}
grid.push( row );
}
return( grid );
}
});
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I manage the Form widget.
var DemoForm = React.createClass({
// I handle user-based changes on the input form. When the user updates the
// filtering, we need to let the calling context know about it.
handleFilterChange: function( event ) {
this.props.onFilterChange( this.refs.filter.getDOMNode().value );
},
// I handle the user's desire to remount the data.
handleRemount: function( event ) {
this.props.onRemount();
},
// I handle the user's desire to unmount the data.
handleUnmount: function( event ) {
this.props.onUnmount();
},
// I render the view using the current state and properties collections.
render: function() {
var fitlerInsight = null;
// If the user has entered filter text, we want to show some insight into
// the breadth of the filtering.
// --
// CAUTION: We have to have these awkward and explicit spaces { " " }
// because the JSX strips out certain pieces of whitespace, leaving
// the input butted-up against the label.
if ( this.props.filter ) {
fitlerInsight = (
<span>
—
Filtering <strong>{ this.props.filter }</strong>
{ " " } over { this.props.dataPoints } data points,
{ " " } { this.props.visibleCount } found.
</span>
);
}
// Provide some tooling to unmount and remount the data.
if ( this.props.isMounted ) {
var mountAction = <a onClick={ this.handleUnmount }>Unmount Grid</a>;
} else {
var mountAction = <a onClick={ this.handleRemount }>Remount Grid</a>;
}
// CAUTION: We have to have these awkward and explicit spaces { " " }
// because the JSX strips out certain pieces of whitespace, leaving
// the input butted-up against the label.
return(
<form>
<strong>Filter Data</strong>:
{ " " }
<input
type="text"
ref="filter"
value={ this.props.filter }
onChange={ this.handleFilterChange }
/>
{ " " }
{ fitlerInsight }
{ " " }
{ mountAction }
</form>
);
}
});
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I manage the Table widget.
var DemoTable = React.createClass({
// I render the view using the current state and properties collections.
render: function() {
// If the table is being filtered, we want to add a class to the table to
// set a default style for all the non-hidden elements.
var tableClasses = this.props.filter
? "filtered"
: null
;
// Creating a local reference so we don't have to .bind() the iterator.
var filter = this.props.filter;
// Translate the grid into a collection of rows.
var rows = this.props.grid.map(
function transformRow( row ) {
return(
<DemoTableRow
key={ row.id }
row={ row }
filter={ filter }
/>
);
}
);
return(
<table width="100%" cellSpacing="2" className={ tableClasses }>
<tbody>
{ rows }
</tbody>
</table>
);
}
});
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I manage the Table rows.
var DemoTableRow = React.createClass({
// I render the view using the current state and properties collections.
render: function() {
var columns = [
<td>
{ this.props.row.id }
</td>
];
// Creating a local reference so we don't have to .bind() the iterator.
var filter = this.props.filter;
// Translate each item into a TD element. If there is filtering being
// applied, some of the TD elements will have the "hidden" class.
this.props.row.items.forEach(
function transformItem( item ) {
var classes = "item";
if ( filter && ( item.value.indexOf( filter ) === -1 ) ) {
classes += " hidden";
}
columns.push(
<td key={ item.id } className={ classes }>
{ item.value }
</td>
);
}
);
return(
<tr>
{ columns }
</tr>
);
}
});
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// Render the root Demo and mount it inside the given element.
React.render( <Demo />, document.getElementById( "content" ) );
</script>
</body>
</html>
Personally, I find ReactJS a bit harder to reason about since the rendering tends to get spread across a larger swath of code. And, even within the scope of a single render() method, the logic always seems to be "inside-out" in so much as the inner, conditional elements get calculated before the container in which they are rendered. But, I'm going to chalk this all up to my inexperience with the "React way."
Overall, I am impressed with React's ability to render HTML so quickly. But, as of this time, I don't yet feel a strong need to start converting AngularJS code over to ReactJS code. I am still much more comfortable with AngularJS. And, in most situations, I dealing with such a small amount of data that the performance differences are inconsequential. That said, I do think there are a number of opinions that the "React way" imposes that I think would have much value in an AngularJS context. So far, I am quite pleased with the cross-pollination of ideas and I intend to keep digging into ReactJS.
Want to use code from this post? Check out the license.
Reader Comments
I don't know if it's the "React Way", but I've been using React for about a year and a half and for the sake of comparison, this is how I would have written those components:
https://gist.github.com/insin/a60531c03424fbfc872f#file-index-html-L78
One of the key differences is moving that "inside-out" rendering inline with what's being returned from render(). It also uses ES6 features (enabled in JSXTransformer by adding harmony=true to the script tag) such as destructuring of props and fat arrows when mapping.
Hi Ben Nadel,
It such a interested blog post,
I have found that you have avoided using $scope in your angularjs controller to speed up angularjs version, also used track by in ng-repeat improve speed too, smart guy you are, but Reactjs still is winner in this situation, Reactjs even faster If you use js code that complied from jsx code in your Reactjs version. btw, In my opinion the code in angularjs version is still easier, more readable than Reactjs code.
Thanks Ben
It will be interesting to come back in 6 months time and do the same comparison with Angular2.
I am following the A2 development quite a bit, and it seems like the Angular team is talking a lot with the React developers.
I'm a huge fan of AngularJS and didn't get all the fuss over ReactJS. Then I met RiotJS. It would be interesting to see how RiotJS compares in this test. If I get some time this weekend I may have a go at it.
The difficult part for me with ReactJS was it was solving a problem I didn't have. I was fine with how my views work (in Angular). It's my models and updates I wanted to improve.
It was only when I started using Flux style architecture that I saw the problem ReactJS was solving. I think a conversation about ReactJS should start with Flux because that's the context where ReactJS makes the most sense.
So back to RiotJS. RiotJS takes the best parts of ReactJS and boils it down to its essence. Then it adds a (very nice) coat of syntax sugar that makes the experience much more pleasurable.
Sorry, I'm a bit a fan boy right now, but RiotJS really has me thinking about how I approach apps.
Absolutely no criticism but I noticed the use of "fitlerInsight" in one of your examples. ... It's use is consistent but I thought I should mention it.
Great work BTW!
Here's a Riot version, too. Need to get someone with more Riot experience to vet it for best practices, though, the update() calls and the parent.parent stuff doesn't seem right to me.
https://gist.github.com/insin/6c080fc215d421350418#file-index-html
@Jonny, Nice!
@Jonny,
The inlining approach is interesting. As you can probably guess from the amount of my white-space (in general), I sometimes have trouble concentrating on the code when it is too tight. I think I'll have to play around with that approach a bit more. In my recent reading, I have seen other React devs doing similar things.
@Sonny,
The JSX does have overhead, but only on the initial page load. Once the page is loaded and the JSX is compiled, the different (to my understanding) should be gone. And, I have to say, while I was hope that AngularJS was the winner, it still did really well. The initial rendering / re-mounting was noticeable slower. But, handling updates to the existing rending was still *really fast*. In other demos that I've seen, people seem to say that you don't get good performance out of AngularJS until 2.0 - but, that has never been my experience. AngularJS is just a solid framework!
And, I agree - I still think the AngularJs stuff is easier to reason about. But, people who have more experience with React say that it [React] is easier to reason about. So, I'll probably just chalk each side of experience - you probably just get more comfortable with XYZ when you use it.
@Lars,
From what I've heard in passing, it does seem like AngularJS 2.0 is going to be much smarter about the way it handles data. I keep hearing about this "constant time" diff-checking; but, I haven't really dug into any of it yet.
Heck, I've never even used an ES6-ES5 transpiler yet, which I think is step-one when it comes to experimenting with AngularJS 2.0.
Honestly, I have no need to abandon Angular. It's been awesome and I think it will only continue to get better.
@Ray,
Ah, nice catch. I swear that sometimes that spell-checker just hates me :D
@Mike,
I've not heard of RiotJS - I'll take a look Jonny's code in a bit. But, I am very interested in the Flux approach. I haven't gotten that far in my exploration yet. That said, from what I have seen so far, I think that a lot of the "React way" things can actually (and should actually) be translated into an AngularJS context. Some of the hard opinions that it takes on who owns and can mutate data just "make sense," regardless of AngularJS. Unfortunately, AngularJS basically has no opinion on it in the documentation, which I think is why it feels so new in React.
I'd love to start looking at using React views in Angular, which I know some people have had good luck with. I don't quite have the mental model for that just yet, though.
@Ben,
Have you had the chance to test the same scenarios with the relatively new one time binding of Angular 1.3 with the (::) notation. Internally we found it to double or triple rendering speeds of large sets.
It's a similar approach of dealing with immutable objects.
@Ganriel,
Great question - I went back and forth on this. I actually had it on the cell-rendering values for while, {{ ::row.id }} and {{ ::item.value }}. But, I eventually opted since I wanted to try to keep the two approaches as similar a possible. While I am never changing the grid values, ReactJS would have correctly rendered a change (since it doesn't have the concept of one-time bindings, per say). As such, I wanted the AngularJS code to have the same theoretical flexibility.
That said, I actually didn't notice too much difference when I had them in. But, I am using 1.4.2 in this version, which may have more speed improvements over earlier versions of the library. Certainly, there was some speed improvement and, if I was building this "for real", I would absolutely use them.
@Mike, @Jonny,
Wow, that RiotJS version is interesting. Not having an understanding of how RiotJS works, it's a bit harder to follow as it seems like things are just haphazardly mixed together. But, I am sure that once you understand the syntax it makes sense. It looks like the JavaScript can just be anywhere inside the top-level of the Component? Not sure. Super interesting though.
@Ben,
The rules are pretty simple. A component is an HTML tag (lower case only) followed by JavaScript.
Use of the script tag is optional. I usually include it. It does look a bit strange when the script tag is missing.
Components are usually in separate files. For purposes of this experiment, they're all included in the one file.
I've created a Mithril version of this example (making use of its newly-added component support) and put all the versions so far here so they're easy to flick through:
https://insin.github.io/ui-lib-samples/large-datasets/
@Jonny, thanks for putting Mithril out there. Speed is amazing (I was previously impressed by Riot and React). Need to test it further and getting the word out there.
It's so interesting to see the different approaches next to each other. None of them provides seamless performance at this size of dataset. And, none of them seems particularly problematic.
Thanks for running these tests. The results were very interesting. Lately I've been seeing issues with two-way data binding that a flux architecture would prevent. So I've been reading up on React and Riot. React at first is a little daunting so I took a spin with Riot just to get a sense of what's going on and I'm a convert.
But after a week or so I'm starting to reach the limits of Riot, which for a small library isn't such a bad thing. But I'm finding that I'm wanting some sort of state management system. Riot is an amazing library for what it is but I can see myself needing to step up to react soon to scale my projects.
Thanks again for doing this test.
It's Jaime, not Jamie ;)
Apart from that, yet another great post! Keep up the great work!
Its been some time but the problem we currently experiencing is that we are using wrap bootstrap templates with various jquery components and React needs some migration so to speak. So I was thinking about Riot. I do read that a lot of people are mentioning that Riot has a limit but after playing around I just cannot see something that stands out. Is there something I am missing?
@Will,
Did you try using Riot with RiotControl?
Using stores makes events and states flexible to manage - even form other scripts.
@All,
I re-visited this experiment with Angular 2 Beta 3:
www.bennadel.com/blog/3016-rendering-large-datasets-with-angular-2-beta-3-and-reactjs-0-14-7.htm
From what I can see, ReactJS is still faster when it comes to the initial building of DOM nodes; but, am very excited to see that AngularJS clearly has strong performance improvements when it comes to updating existing DOM elements.
@Ben,
- gonna be interesting when the Angular team gets template precompilation done (hopefully soon)
Hello! For those who need to localize their web application, I recommend using a software localization tool like https://poeditor.com/ because it will help you better manage your projects.