A bit of React in an Angular recipe
Why we get here? Apparently, we reach a point where we have met one of the bad parts of Angular (ng-repeat performance) and one of the good parts of React (virtual DOM).
This is the problem
When you have got a dynamic data table, where the user can setup and select the number of columns to display or levels of depth for a custom search application that ends up in an indeterminate number of rows, you know that nothing can go worst. In addition, is a multilanguage application so we will need to translate some strings and format some dates. Of course, always someone can ask for IE support, and you know for sure they will do. That is the case. The result: the table freezes when it loads too much data.
What can we do
As web developers, we are used to face unexpected problems and solve them, so it is not a different scenario, neither will be the steps we are going to take :
- investigate where can we improve the performance of the application
- refactor and test
- go back over these steps until the application feels responsive
After following all tips found in internet (check this interesting article on how to improve Angular web app performance) and no really benefit found, it was time to look for another option. There are lot of modules that will help us with our job but they have some drawbacks: big footprint, provide lot more features than needed, lack of one or more of our requirements. Why not try a different path? Since ReactJS is around, lot of people have tried to mix it with any other existent library or framework, included (and recommended for its clarity) this article by Pete Hunt, one of the creators of React. Comments have been always positive about performance, so there are no reasons not to have a go and decide for ourselves.
LET´S GET IT STARTED
Now that we have our application up and running, we only need to add React to the mix:
bower install react --save
Chances are that you are using Karma for your tests. If that is the case, in order to avoid problems you´d better install phantomjs-polyfill and add it to your karma conf file:
npm install --save-dev phantomjs-polyfill
Let´s start with the old table data.
<table class="table table-hover">
<thead>
<tr>
<th ng-repeat="header in ctrl.ListModel.selectedColumns track by $index" ng-if="header.visibleInResultTable">
<span ng-if="!header.via">
{{header.objectName|translate}}.{{header.attributeName|translate}}
</span>
<span ng-if="header.via">
{{header.objectName|translate}}.{{header.attributeName|translate}}
<small>
<sup class="fa fa-question" tooltip-placement="left"
tooltip="via: {{header.via|translate}}"></sup>
</small>
</span>
</th>
</tr>
</thead>
<tbody ng-if="ctrl.ListModel.collection.length === 0">
<tr>
<td colspan="8" class="text-center"
translate="NO_RESULTS">
</td>
</tr>
</tbody>
<tbody ng-if="ctrl.ListModel.collection.length > 0">
<tr ng-repeat="item in ctrl.ListModel.collection track by $index"
ng-click="ctrl.gotoDetail(item)">
<td ng-repeat="col in item.values track by $index"
ng-if="col.visibleInResultTable">
<span ng-if="col.propertyType === 'date'">
{{::col.propertyValue|date:'dd.MM.yyyy'}}
</span>
<a ng-href="{{col.propertyValue}}"
ng-if="col.propertyType !== 'date' && col.weblink && col.propertyValue !== ''" target="_blank">
<span translate="OPEN_PAGE"></span>
<span class="fa fa-external-link"></span>
</a>
<span ng-if="col.propertyType !== 'date' && !(col.weblink && col.propertyValue !== '')">{{::col.propertyValue}}</span>
</td>
</tr>
</tbody>
</table>
To start with our refactor, the first obvious step is to define the directive where the React component will live and add the bindings for the data that is going to update the table header and content.
<custom-table table-header-data="ctrl.ListModel.selectedColumns" table-body-data="ctrl.ListModel.collection.values"></custom-table>
Then, we create the directive:
function customTable(){
return {
restrict: 'EA',
scope: {
tableBodyData: '=',
tableHeaderData: '='
},
link: function(scope, element, attr) {
scope.$watch('tableBodyData', function(newValue){
if (!newValue) {
return;
}
React.render(
React.createElement(CUSTOMTABLE, {
scope: scope
}),
element[0]
);
});
}
};
}
In this directive, the interesting thing, besides the React.render()
method is that we pass to this component the scope
as a property object. This is the way we pass data to the component. Also notice the $watch
, that will take care of any changes in the model and render the changes in the component, passing any new data through the scope. For our React component (our brand new table) we are going to follow the tips given in Thinking in React (step 1) and split the UI in component hierarchy (table, thead & tbody
). This will be our table component (CUSTOMTABLE):
var CUSTOMTABLE = React.createClass({displayName: 'CUSTOMTABLE',
render: function() {
var tableBodyData = this.props.scope.tableBodyData,
tableHeaderData = this.props.scope.tableHeaderData;
return (
React.createElement('table', {
className: 'table table-hover'
},
[
React.createElement('thead', null, header),
React.createElement('tbody', null, rows)
])
);
}
});
With a simple loop, populate the table header with all needed th elements as well as any other inline element we are going to use (links, icons, etc.). If you are asking yourself, what the hell is this React.createElement
please, continue reading. We will explain in the next paragraph.
var headerFields;
for (var l = 0; l < tableHeaderData.length; l++) {
if (tableHeaderData[l].visibleInResultTable) {
if (!tableHeaderData[l].via) {
headerFields.push(
React.createElement("th", null, this.props.tableHeader[l].entity + "." +
this.props.tableHeader[l].property)
);
} else {
var sup = React.createElement("sup", {
className: "fa fa-question",
title: "via: " + this.props.tableHeader[l].via
});
headerFields.push(React.createElement("th", null, [this.props.tableHeaderData[l].entity + "." +
this.props.tableHeaderData[l].property, sup]));
}
}
}
var header = React.createElement("tr", null, headerFields);
By the way, we have deliberately avoid JSX syntax to keep as small footprint as possible, and because is not so hard. Check this guide. The syntax to create an element is fairly simple: ● createElement
method ● name of the element (tag name) ● properties object (html valid attributes, custom attributes will need data
– prefix) ● variable number of optional child arguments (Content could be a text node or more elements) We are going to continue with the table content, that doesn't differ much from the table header. We start by checking the content length, so we could display a message when no result is retrieved.
var rows = []; //we will store all rows
if (!tableBodyData || tableBodyData.length === 0) {
columns = React.createElement('td', {
className:'text-center',
colSpan: 8
}, 'NO_RESULTS');
rows.push(React.createElement('tr', null, columns));
}
Otherwise we will iterate over the data to create the grid.
} else {
for (rowIndex = 0; rowIndex <= tableBodyData.length; rowIndex++) {
columns = [];
for (columnIndex = 0; columnIndex < tableBodyData[rowIndex].length; columnIndex++) {
col = [];
if (tableHeaderData[columnIndex].visibleInResultTable){
if (tableHeaderData[columnIndex].type === "date"){
col.push(React.createElement("span", null,
this.props.tableBodyData[rowIndex][columnIndex])
);
} else if (tableHeaderData[columnIndex].weblink){
let icon, text;
text = React.createElement("span", null,"OPEN_PAGE");
icon = React.createElement("span", {
className: "fa fa-external-link";
});
col.push(React.createElement("a", {
target: "_blank",
href: tableBodyData[rowIndex][columnIndex]
},
text, icon
));
} else {
col.push(React.createElement("span", null,
tableBodyData[rowIndex][columnIndex]));
}
}
columns.push(React.createElement("td", null, col));
}
clickFn = this.props.scope.$apply.bind(
this.props.scope,
this.props.scope.navigate.bind(null, tableBodyData[rowIndex])
);
rows.push(React.createElement("tr", {
onClick: clickFn
}, columns));
}
}
That's it, here is the table up and running. But wait, we still need to dig into some details, and tweak this component.
Devil in details
As you could see at the end of the last code example, we add event listeners for every row of the table in order to access to the item detail. The listener registers a handler for this event and gives the control back to Angular. Remember that now we are in a React component, so we need Angular to be aware of any changes from the outside world. Of course, for this handler to work, we need to define our custom function in the directive and bind it to the proper controller.
function customTable($rootScope){
return {
restrict: 'EA',
scope: {
tableBodyData: '=',
tableHeaderData: '='
},
controller: function(scope){
scope.goToDetail = function(rowData){
// Logic to navigate
};
},
link: function(scope, element, attr, ctrl){
scope.$watch('tableBodyData', function(newV){
if (!newV) {return;}
React.render(
React.createElement(CUSTOMTABLE, {
scope: scope
}), element[0]
);
});
scope.navigate = function(row){
ctrl.gotoDetail(row);
};
}
};
}
Almost there. The table render faster than before and update changes equally fast, although you have to remember that we need to deal with translations for some cells and of course, date formats. So, how can we achieve this? For i18n we are using the awesome Pascal Precht´s Angular translate, and we know it is flexible, but we are still outside of Angular environment, so no translate="key_to_translate"
neither {{'key_to_translate' | translate}}
will work. Anyway, we try with data-translate
because React only accepts html valid attributes, but we only get the attribute added to the element but not translation at all. In this case, everything points to compile the element to process this directive. This is not what we want. Anyway, remember that when we create the React element in our directive we could pass the scope, so why not pass any other thing we need, like $translate
service, or even better, why not the angular $filter
service. This way we can deal with the date formats and translations by mean of a filter. Let's try.
function customTableComponent($filter, $rootScope){
return {
restrict: 'EA',
scope: {
tableBodyData: '=',
tableHeaderData: '='
},
controller: function(scope){
scope.goToDetail = function(rowData){
// Logic to navigate
};
},
link: function(scope, element, attr, ctrl){
scope.$watch('tableData', function(newV){
if (!newV) {return;}
React.render(
React.createElement(CUSTOMTABLE, {
scope: scope,
filter: $filter
}),
element[0]
);
});
$rootScope.$on('$translateChangeSuccess', function(){
React.render(
React.createElement(CUSTOMTABLE, {
scope: scope,
filter: $filter
}), element[0]
);
});
scope.navigate = function(row){
ctrl.gotoDetail(row);
};
}
};
}
Now, when we create the table or update the data we are going to pass the new data and filter. Also, whenever the application changes the language, any changes in data or translation will be updated. But, how we make use of this $filter
in the React component?. As well as we do with the data, that is available in this.props.scope
, our filter will be available as this.props.filter
. Let's take a look at the table header
headerFields.push(React.createElement('th', null,
this.props.filter('translate')(tableHeaderData[l].entity) + '.' +
this.props.filter('translate')(tableHeaderData[l].property)
));
We could do the same for date formats:
col.push(React.createElement('span', null,
this.props.filter('date')(tableBodyData[rowIndex][columnIndex],'dd.MM.yyyy')) );
Or any other text:
React.createElement('span', null, this.props.filter('translate')('OPEN_PAGE'));
Finally, the refactor is done. Probably, some of you will think that this is worthless without any real code to test in Plunkr, or numbers to compare performance between the two approaches, but it would have taken other complete article so, who knows, probably the next time we will talk about it. P.D.: All this code will be available publicly as a gist.