Kapost Engineering

Recent Posts


Recent Comments


Archives


Categories


Meta


Building a Tree Navigation Component in React

Justin HundleyJustin Hundley

Dealing with large, complex sets of metadata can be quite challenging for front end applications, both from a UI and performance perspective. At Kapost, we help our customers manage complex sets of field metadata for organizing their content. One of the ways we are doing this is with the development of a Multilevel Tree component that lets our users model and manage their metadata easily with one component. I’m going to talk through our approach to this problem, and the solution we came up with through a simple example (full example code included!).

The Requirements:

Let’s start building our component, and we’ll explore some obstacles along the way. If you want to skip ahead, you can jump to the completed example component below.

First, let’s decide what a good object structure will be. We are going to have a schema that resembles a tree, and consistency is always important so why don’t we opt for something like…

const sampleTree = [
  {
    label: "A Group",
    value: "a",
    children: [
      { label: "Child Group A", value: "child_a", children: [] }
    ]
  },
  {
    label: "Group B",
    value: "b",
    children: [...]
  }
];

In our case, we decided to construct our API to return our data in a structure very similar to this, so that we minimize the front-end transformation we need to do to get our data in a usable form by our component. Note that our children are structured the same as our root level objects, which will allow recursive rendering strategies.

Now that we have an idea of our main structure, let’s start by thinking of a basic render structure (using lodash for some convenient helpers).

  renderItem(item) {
    return (
      <div key={item.value} className="item">
        {item.label}
      </div>
    );
  },

  renderItems() {
    const items = this.props.items;

    _.each(items, (item) => {
      this.renderItem(item);
    });
  },

  render() {
    return (
      <div className="tree">
        <div className="panels">
          { this.renderItems() }
        </div>
      </div>
    );
  }

This is a good start. With this structure we could set up a container with a fixed height and scroll overflow to get a scroll-able container for our tree. Here we are assuming that everything is a single “item”, which can be thought of as a “leaf” of our tree. However, we know we’ll want to render “groups” (“branches” of our tree) differently, and of course they will have different behavior when clicked. So we need to include some logic for rendering our branches differently.

  renderItem(item) {
    return (
      <div key={item.value} className="item" onClick={()=> alert("item")}>
        {item.label}
      </div>
    );
  },

  renderGroup(group, index) {
    return (
      <div key={group.value} className="group" onClick={()=> alert("group")}>
        {group.label}
      </div>
    );
  },


  renderItemOrGroup(itemOrGroup) {
    if (_.isArray(itemOrGroup.children)) {
      return this.renderGroup(itemOrGroup);
    } else {
      return this.renderItem(itemOrGroup);
    }
  },

  renderItems() {
    const items = this.props.items;

    return _.map(items, (item) => {
      return this.renderItemOrGroup(item);
    });
  },

  render() {
    return (
      <div className="tree">
        <div className="contents">
          { this.renderItems() }
        </div>
      </div>
    );
  }

Great. Now we can control what items and groups look like separately, and how their click behavior will differ.

Now there are a couple of glaring issues. First, we still do not have a way of managing what “level” of our tree is being rendered. Currently we are rendering only the root level, and have no concept of navigating up and down the tree. Second, we have not addressed our scalability requirement yet… what if we have a large number of items in our tree? As an experiment, check out this CodeSandbox below and try adjusting the number of items we are rendering:

The time to render completion and overall scrolling responsiveness starts to become noticeably slow after around the 10,000 item mark. That’s just not acceptable when trying to build for scale… but how do we get around the fact that we just have so much data we need to display?

Enter react-virtualized.

The react-virtualized library is perfect in this situation. I’m not going to go into the details of how this library works, but in short it can render a large number of items by only rendering the viewable portions. It listens to scroll events to load in more items as needed. This means that if we have thousands of items in our tree of metadata, we won’t ever have an issue rendering them. The specific component we are going to use is the <List/> component which takes all of your items you want to render and a height, and handles all of the calculations for what to render internally.

So instead of rendering all of our items, we can use a List to render a more reasonable amount. Let’s take a look and see how that solves our item rendering scalability issue:

If you open up your dev tools, you can see how react-virtualized only renders the visible DOM elements as you scroll (plus a buffer so that you don’t see any wonky behavior when scrolling quickly).

The other problem we mentioned is how we are going to handle navigating up and down our tree. Based on the structure of our metadata, we have set ourselves up to pretty easily render the children of a “group” when clicked. This seems straightforward… when we click on a group, we can change our current set of “items” to be the children of the group we just clicked. However, without forcefully re-rendering the <List/> component (which is possible if we look at the available methods in the docs), we won’t be able to dynamically update what our List is rendering as we switch to different branches (since it does internal calculations when it mounts).

Also, if we remember our usability requirement, we would highly prefer to have smooth CSS transitions as a user navigates through their the tree of metadata. To do this we need multiple DOM elements mounted for each “panel” (level of the tree you are on) so that we can show a CSS translation animation between the two levels of the tree.

We solved these two problems with one solution, which is essentially to build a virtualized List panel for each active level of our tree. To describe this, let’s take a look at what our approach is going to be for our transitions:

We can maintain state of what our current path is in the tree (what level we are at), and use that to render a List component for each panel. That way we can use CSS transitions to show navigation between them. We need a back button too so we have way of navigating in the reverse direction. Let’s go to our updated sandbox with this pattern:

What’s happening here is every time we click into a branch, we update the selectedPath with the newly selected group. When our state updates, our renderChildPanels function will create an additional virtualized List panel for that new branch. With some extra CSS class logic to specify which panel is “active”, and some semi-magical SCSS styling, we have a fully functional tree.

Our SCSS code (note that I compiled it and included in our sandbox):
https://gist.github.com/Fireworks/fd26820377487cc81d2a551fbb1bdb67

The CSS here may look complex, but it really isn’t too bad. Besides basic styling, the key piece here is the positioning. We absolutely position each panel next to each other as shown in our previous mock-up, and simply set the transform property of our active panel respectively. This allows our panels to stack and transition smoothly from one to the next. We could further optimize by only rendering the “edges” of the active panel (previous and next), but that may introduce complexity to our component that is unnecessary. We won’t be expecting users to navigate thousands of levels deep into their tree (and if they are, something is very wrong with their metadata schema!). We can see that the panels stack now as we expect:

There are a few extra touch ups we can make before we wrap up. For one, when we navigate up the tree, since we are immediately changing our selectedPath state, the current panel with be unmounted before the transition completes. This looks a little bit jarring, and prevents a smooth transition. (Note that this isn’t a problem in the forward direction because the next panel is mounted before the transition occurs). For this issue, we can easily wrap our panels in a CSSTransitionGroup, which will cause a delay before the component unmounts.

Another potential issue is that we may not always want to hard code our List dimensions. Ideally this would be externally controlled for different situations (remember our flexibility requirement?). Luckily, react-virtualized has another component called the AutoSizer which we can use to automatically determine the available dimensions of the container, and pass those down to the children. That way we can externally control both width and height so that we aren’t hard coding values in our base component.

So pulling everything together, we have our final component:

 

Reviewing Our Requirements:

With react-virtualized we can render hundreds of thousands of items without worrying about render performance. Scalability met.

Our component is easy to use, and with the CSS transitions we have visually appealing, intuitive tree navigation. Usability met.

And finally, from here the component could be expanded in a number of ways. For example, we could wrap this in a Dropdown component so that the tree is not always visible. We could create a higher order component that acts as a “select input” so that a value can be selected out of the tree. With this solid base component, there are many different usages that can be wired up by composing additional components. Flexibility met.

Thanks for following along, and you are welcome to use this base component code for your own implementation. We’d love to hear about your experiences with complex front-end input components, and feel free to reach out with any questions!

full stack engineer at kapost, with an emphasis on front end development. drop me a line: justin@kapost.com

Comments 0
There are currently no comments.