Creating a tree view using React.js

5:30 PM Ricardo Memoria 104 Comments

This article is a tutorial about how to create an extendable tree view component using the awesome React.js framework.

The idea

It’s been quite common in projects I've been working with to display information in a tree view, and dealing with that in an HTML page and pure javascript is always painful. So I decided to create a tree view component using React.js that I could easily customize it, from a simple tree to a tree view table, with not much work.

For the impatient:

Dependencies

In this example, my tree view will have the following dependencies:

The component

I want the tree view to expose the minimum and most meaningful set of properties to the parent component. Ideally, the parent component should not deal with implementation details of a tree, like controlling the collapsing and expanding of nodes. So my TreeView component will expose the following properties:

  • getNodesData = function(parent) - Called every time the TreeView must retrieve the children of a parent node. It will return an array of data. The data returned has no relation with the TreeView but just to serve as a reference when communicating with the parent component. For example, when expanding an specific node, the data retrieved before is passed as a parameter representing the node. For asynchronous operation, the function may return a promise.
  • checkLeaf = function(data) - Optional - Just to check if the given data returned from getNodesData() represents a leaf (returning true) or if the node representing the data has children to display (returning false);
  • innerRender = function(data) - Must return a react component or a string as the label that will be displayed beside the node;
  • outerRender = function(comp, data) - Optional - Allows the parent to wrap the node inside another react component. It will allow to create complex compositions, like columns.

Internally, the TreeView will store a tree model in its state. This tree will be initialized with the root nodes, and as children are loaded, the tree model is updated.

Declaring the component

Let's create a new file called tree-view.jsx and declare the TreeView class

import React from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';

// load css styles
import './tree-view.less';

export default class TreeView extends React.Component {
render() {
 // get the root nodes
 const root = this.state ? this.state.root : null;

 // roots were not retrieved ?
 if (!root) {
  const self = this;
  this.loadNodes(null)
   .then(res => self.setState({ root: res }));
  return null;
 }

 return <div className="tree-view">{this.createNodesView(root)}</div>;
}
}

I see it as a good practice to declare one class per file, so the TreeView class is declared as exported and default.
The render function is quite straightforward - It simply checks if the root nodes are available, load them if not, and delegate the rendering to the function createNodesView.

Retrieving the tree nodes

Before rendering the tree, the TreeView must have at least the root nodes. Nodes are created based on an array returned from the parent component. The content of the array is not relevant to the TreeView. The elements will be used to create nodes and make reference to them when communicating with the parent component. So, whenever the TreeView needs to retrieve the children of a node, it will call the function loadNodes:

loadNodes(parent) {
 const func = this.props.onGetNodes;

 if (!func) {
   return null;
 }

 const pitem = parent ? parent.item : undefined;
 let res = func(pitem);

 // is not a promise ?
 if (!res || !res.then) {
  // force node resolution by promises
  res = Promise.resolve(res);
 }

 // create nodes wrapper when nodes are resolved
 const self = this;
 return res.then(items => {
  if (!items) {
   return [];
  }

  const nodes = self.createNodes(items);
  return nodes;
 });
}
  • Line 9 asks the parent component to send the array of children data relative to the parent node. The first call will ask for data of the root node, so parent will be null. Subsequent calls will inform the node data as the parent.
  • Line 12 checks if the result from onGetNodes is a promise. If not, embed it into a promise.
  • Line 19 waits for the promise to resolve. Once it is resolved, it will contain the array of nodes data.
  • Line 24 creates the node objects from the data returned of the parent component.

Node objects will store the state of the node, like if it is a leaf or not, and if it is collapsed or expanded. The implementation of the createNodes function is:

createNodes(items) {
 const funcInfo = this.props.checkLeaf;
 return items.map(item => {
  const leaf = funcInfo ? funcInfo(item) : false;
  return { item: item, state: 'collapsed', children: null, leaf: leaf };
 });
}
  • Line 4 asks the parent component if the data representing the node is a leaf or a node with other children. Notice that the initial state of the node is 'collapsed', but the node may have the states 'expanding' and 'expanded';
  • Line 5 creates the object that will store information about the node;

This function will return an array of node objects used internally by the TreeView.

Displaying the tree

Displaying the tree is nothing but creating the components that will be rendered based on the node tree model stored in the component state. In order to display the tree, the TreeView component will travesse all visible nodes and create react components from them. This is done by the createNodesView() function, called from the render() function:

createNodesView() {
 const self = this;
 
 // recursive function to create the expanded tree in a list
 const mountList = function(nlist, level, parentkey) {
  let count = 0;
  const lst = [];
 
  nlist.forEach(node => {
   const key = (parentkey ? parentkey + '.' : '') + count;
   const row = self.createNodeRow(node, level, key);
 
   lst.push(row);
   if (node.state !== 'collapsed' && !node.leaf && node.children) {
    lst.push(mountList(node.children, level + 1, key));
   }
   count++;
  });
 
  // the children div key
  const divkey = (parentkey ? parentkey : '') + 'ch';
 
  // children are inside a ReactCSSTransitionGroup, in order to animate collapsing/expanding events
  return (
   <ReactCSSTransitionGroup key={divkey + 'trans'} transitionName="node"
    transitionLeaveTimeout={250} transitionEnterTimeout={250} >
    {lst}
   </ReactCSSTransitionGroup>
   );
 };
 
 return mountList(this.state.root, 0, false);
}
  • Line 5 declares an internal function that will recursively travesse the node tree. It will receive the node list, the level of the list in the tree and the parent key used as the key of the react component;
  • Line 10 creates the key based on the parent key and the index of the node in the list;
  • Line 11 creates the react component, by invoking the createNodeRow function;
  • Line 14 checks if the node in the loop is expanded and contains child nodes. If so, call the function recursively;
  • Line 25 is used a react add-on to give animation to the node when it is collapsed or expanded;
  • Line 32 Is where the function is called, returning the list of components to be displayed

In fact, each react component of a node is created by the createNodeRow function, that takes the node object, the level in the tree and node key as parameter:

createNodeRow(node, level, key) {
   const p = this.props;
 
    // get the node inner content
    const content = p.innerRender ? p.innerRender(node.item) : node.item;
 
    // get the icon to be displayed
    const icon = this.resolveIcon(node);
 
    const waitIcon = node.state === 'expanding' ?
  <div className="fa fa-refresh fa-fw fa-spin" /> : null;
    // the content
    const nodeIcon = node.leaf ?
        icon :
        <a className="node-link" onClick={this.nodeClick} data-item={key}>
            {icon}
        </a>;
 
    const nodeRow = (
        <div key={key} className="node" style={{ marginLeft: (level * 16) + 'px' }}>
            {nodeIcon}
            {content}
        </div>
        );
 
    // check if wrap the component inside another one
    // provided by the parent component
    return p.outerRender ? p.outerRender(nodeRow, node.item) : nodeRow;
}
  • Line 5 gets the content that will be displayed beside the node collapse/expand button;
  • Line 8 gets the icon to be displayed as the collapse/expand button, by invoking resolveIcon;
  • Line 11 checks if the node is being expanded. If so, it will include an animated icon spinning while the node is loaded.
  • Line 13 generates the control to display the collapse/expand button. If it is not a leaf, the control will be an anchor, so the user will be able to collapse or expand the node.
  • Line 19 creates the node row, wrapping button and content inside a single div.
  • Line 28 if the parent component provided the property outerRender, it will be called to wrap the node row inside another component.

And this is the implementation of the function that will return the icon to display according to the node state:

resolveIcon(node) {
    let icon;
    if (node.leaf) {
        icon = 'circle-thin';
    }
    else {
        icon = node.state !== 'collapsed' ? 'minus-square-o' : 'plus-square-o';
    }
 
 
    var className = 'fa fa-' + icon + ' fa-fw';
    return <i className={className} />;
}

It uses Font Awesome to display the icon in the node tree.

Adding animation to the collapsing/expanding event

If you notice, when clicking on the plus or minus button, the tree is not collapsed or expanded immediately, but there is a quick animation that slides the children up or down smoothly. This effect is achieved with the animation features of CSS3 and the React add-on animation. One of the coolest features of Webpack is that you can embed CSS styles per javascript module. This is done by including it in the import section:

import from 'tree-view.less'

And the TreeView css style is

.tree-view {
 .title {
  font-weight: bold;
 }
 
 .node-link {
  cursor: pointer;
 }
}
 
.node-enter, .node-leave {
 display: block;
 overflow: hidden;
}
 
.node-enter {
 max-height: 0;
}
 
.node-enter.node-enter-active {
 max-height: 500px;
 transition: max-height 250ms ease-in;
}
 
.node-leave {
 max-height: 500px;
}
 
.node-leave.node-leave-active {
 max-height: 0px;
 transition: max-height 250ms ease-out;
}
 
.node-row {
 border-top: 1px solid #f0f0f0;
}

Actually it is not CSS, but Less, which gives a lot of extra features to create your CSS styles. Behind the scenes, Webpack transform this less file in css and embed it in the TreeView code. In order to give that smooth animation of nodes collapsing and expanding, these CSS elements are automatically included and removed by the react add-on animation module, which is applied in the function createNodesView:

createNodesView() {
  ..
  ..
  ..
 
  return (
   <ReactCSSTransitionGroup key={divkey + 'trans'} transitionName="node"
    transitionLeaveTimeout={250} transitionEnterTimeout={250} >
    {lst}
   </ReactCSSTransitionGroup>
   );

Conclusion

The TreeView is a basic component, but it can be easily customized and modified. In the source code at the top of this page, I made a lot of small improvements in the TreeView, like the possibility to change the icons being displayed and a property to display a header title at the top of the tree. Modify it as you wish. Have fun...

The original link for this post is here

104 comments: