Codebase deep dive: ReactTransitionGroup

This week, I needed to read through the code of ReactTransitionGroup in order to modify it for one of my apps at work. Here is my commentary on it. This is the link1 to the GitHub repo.

The maintenance and further development of ReactTransitionGroup will be transferred to the community because a lack of use cases has caused the package to be neglected by the React core team. ReactTransitionGroup will be removed from the React core in v15.5.

ReactTransitionGroup

In addition to the explicit component prop and implicit children prop, a ReactTransitionGroup also takes an undocumented childFactory prop, which allows you to modify each child element before rendering it to the DOM. childFactory defaults to the identity function in ReactTransitionGroup but is used by the higher-level ReactCSSTransitionGroup to put each child inside a wrapper element that adds or removes appropriate CSS classes when that child enter or exits the DOM.

The transition group uses an internal childRefs property to keep track of which children is actually mounted in the real DOM at any one time. This includes elements that are entering, staying and leaving. The group also uses a currentlyTransitioningKeys to keep track of which children are being animated in or out of the DOM.

When a ReactTransitionGroup has already been mounted in the DOM, changing from one set of children to another set involves the following steps, carried out by the componentWillReceiveProps method (reproduced below):

  • Immediately, React try to figure out how to merge the old and new set of children in a way that causes the least amount of disruption to their ordering in the actual DOM by calling mergeChildMappings in the ChildMappings utility module. Miminizing changes to the ordering of DOM elements is necessary to minimize any mounting and unmounting of DOM elements, which will disrupt any CSS transitions for those DOM elements.

  • React then renders this merged group of children into the DOM. Elements that were already in the DOM will of course not experience any change because their keys stay the same.

  • Then React divides this set of elements into three groups and animate them using two for loops2:

    • Those that wasn’t in the previous props.children are added to this.keysToEnter property so that they’ll receive the enter transition.
    • Those that aren’t in the current props.children are added to this.keysToLeave property so that they’ll receive the leave transition.
    • Those that stay receive no special treatment because they will be handled appropriately by React’s own reconcilliation process.
  componentWillReceiveProps(nextProps) {
    let nextChildMapping = getChildMapping(nextProps.children);
    let prevChildMapping = this.state.children;

    this.setState({
      children: mergeChildMappings(
        prevChildMapping,
        nextChildMapping,
      ),
    });

    for (let key in nextChildMapping) {
      let hasPrev = prevChildMapping && prevChildMapping.hasOwnProperty(key);
      if (nextChildMapping[key] && !hasPrev &&
          !this.currentlyTransitioningKeys[key]) {
        this.keysToEnter.push(key);
      }
    }

    for (let key in prevChildMapping) {
      let hasNext = nextChildMapping && nextChildMapping.hasOwnProperty(key);
      if (prevChildMapping[key] && !hasNext &&
          !this.currentlyTransitioningKeys[key]) {
        this.keysToLeave.push(key);
      }
    }

    // If we want to someday check for reordering, we could do it here.
  }

Note that the accounting of which elements should leave or stay happens right after the setState call and implicitly relies on the fact that setState calls are asynchronous 3. Had setState been synchronous, componentDidUpdate would have been called between lines 55 and 57 and the transitions called in componentDidUpdate wouldn’t have worked because this.keysToEnter and this.keysToLeave would be empty arrays inside componentDidUpdate.

The newly merged state.children are then rendered to the virtual DOM by render. This method transforms each child with the chidlFactory prop. It also uses React.cloneElement to give each child element a ref callback so that it adds itself to the the transition group’s childRefs.

After the merged children are flushed to the DOM, componentDidUpdate triggers the enter animation for each element in the array this.keysToEnter and resets the array to empty. The same process happens for leaveing elements.

performEnter marks the input child React component as being transitioned and get ahold of the actual DOM element backing that component. If the child element declares a componentWillEnter method, that method will get invoked and passed the _handleDoneEntering method as a callback argument. If the child element doesn’t declare that method, _handleDoneAppearing is executed immediately.

_handleDoneEntering calls the child component’s componentDidEnter method if it exists. It then removes the child from the list of currentlyTransitioningKeys. It also nicely takes care of the case when the currently entering element needs to be removed from the DOM before the transition is completed. In that case, the leave transtion is triggered.

_handleDoneLeaving is very similar to _handleDoneEntering except that is also update state.children by removing the exiting child. Note the callback form of setState: setState(function(currentState, props) => newState) instead of the more typical form setState(nextState). This will trigger a re-render in the virtual DOM, which may or may not cause reconcilliation to happen right away.

It’s interesting that the code uses the delete operator to remove an object’s property instead of nulling it out. Performance-wise, deleteing an object is about 70% slower than nulling. The main reason has to do with hidden-classes used in the V8 engine. Basically, deleteing a property invalidates some of the assumptions the JavaScript engine makes about the shape of the transition group object in order to optimize property access. Using null instead of delete wouldn’t have any adverse effect because React children that are null won’t be rendered to the DOM. On the other hand, because ReactTransitionGroup is supposed to serve pretty simple use cases, it usually doesn’t have a lot of children so this approach is probably fine.

ChildMapping

The utility functions in this helper module are pretty interesting:

  • getChildMapping converts a list of React elements into key-value pairs where the keys are the user-defined React elements and the values are the corresponding elements. This is why the documentation says

    You must provide the key attribute for all children of ReactCSSTransitionGroup, even when only rendering a single item. This is how React will determine which children have entered, left, or stayed.

    If you forget the key, the element will “fall through the crack” and will not receive any enter/exit transition. Also note the use of the the React.Children API. You should not access the this.props.children directly because it is an opaque structure4 but rather use the React.Children API.

    getChildMapping can also deal with the case where the input is falsy. This happens when the function is called from within a component that has already been unmounted e.g. from within the “done” callback of componentWillAppear and componentWillEnter.

  • mergeChildMappings interleaves the old with the new keys while aiming for a sensible order. The order is obtained by “lining up” the common keys between the old and new keys. Between the “uncommon keys”, the new keys are shown first followed by old ones. For the example given in this pen (the underscores represent white spaces):

    key-2_key-1_____________key-5_key-7_______key-8 (old keys)

    ______key-1_key-3_key-6_______key-7_key-9______ ( new keys)

    key-2_key-1_key-3_key-6_key-5_key-7_key-9_key-8 (merge result)

    An interesting consequence of this merge’s implicit reliance on ECMAScript’s ordering of an object’s keys is that if some of the keys are “numeric” e.g. 7 instead of key-7, this merge method will give completely different results:

    1 2 3 4 5 6 7 8 9

    The reason is that according to ES2015, all the numeric properties will be listed first in ascending order followed by other string keys. This order will be reflected in the DOM and may cause unintended behavior e.g. unnecessary repositioning between list items if you use the transition group to animate entry/exit of list items. This is also the reason why I use key-1 instead of just 1 in the demo.

Takeaways:

  • Do not use “numeric” keys for children of React transition group children because it will mess up their ordering in the DOM.
  • Always supply a key for each children of the transition group.

  1. Note that this isn’t the official repo from the core React team.

  2. This is similar to D3’s enter/update/exit pattern:

  3. The asynchronous nature of setState is important in React. The official documentation and this short writeup clearly mention that React schedules DOM updates instead of just simply flushes out all the DOM diffs that it can detect on every frame.

  4. The main reason is that there might be many, one or no children at all and this API provides some uniforminity in how to access the children. For a detailed discussion, see this Github issue.