Nomadic cattle rustler and inventor of the electric lasso.
Company Website
Follow me on twitter
Contact me for frontend answers.

React hooks and closures, there may be dragons

February 09, 2019

WARNING, THIS CONTAINS REAL CODE, NO COUNTERS WHERE HARMED IN ORDER TO CREATE THIS POST, REPEAT NO COUNTERS ARE IN THE CODE EXAMPLES. YOU MAY WISH TO LEAVE AND FIND SOME COMFORTING COUNTER EXAMPLES

I am working on a very interesting app that renders a very interactive svg document that uses a combination of react and d3-hierarchy to initially render a dataset that is larger than can fit into the viewport. To counteract this, the document is rendered in a compressed format first in order to try and fit as many elements on the screen as possible:

compressed document view

The compressed view only shows 1 level of the tree, the tree is actually 3 levels deep and the user can either click on a node to expand the children or they can zoom in on a node:

zoomed in document

My problem arose around a requirement where a user can use a contextual search to zoom in on a node:

search menu

After the user has selected an item from the list which corresponds to a node in the tree, the origin of the document is changed to the chosen node’s origin to give the impression that the user is zooming on a chosen node.

expanded view

I have been experimenting with hooks while doing this and I first of all had the following implementation of a TreeContainer that renders a tree of nodes.

React.FC<TreeContainerProps> = ({
  fetchUrl,
}) => {
  const { data: treeData, isLoading, error } = useFetchData(fetchUrl, []);
  const [rootNode, setRootNode] = useState(null);

  useEffect(() => {
    const tree = getHierarchy(root);

    setRootNode(tree);
  }, [treeData]);

  const itemHandler = (node) => {
    const selectedNode = rootNode
      .descendants()
      .find((x) => x.data.id === node.data.id);

    selectedNode.data.isExpanded = true;
    selectedNode.data.active = true;

    const newRoot = getHierarchy(cloneDeep(rootNode!.data));

    setRootNode(newRoot);

    setNewOrigin({ x: nextX, y: selectedNode.y });
  };

  return (
    <>
      <JumpMenu open={modalOpen} itemHandler={itemHandler} />
      <PanZoomTree setExpanded={setExpanded} />
    </>
  );
};

On line 5 I am using useState to return a rootNode variable and a setRootNode function that I can use to trigger re-renders of the tree.

Lines 7 to 11 define a useEffect function that will be called after the first render to set the root node.

I’ve created an itemHandler function on line 13 that I want to pass to a <JumpMenu /> component that will show the popup menu detailed above. I’ve created it as a closure because I wanted access to the rootNode and setRootNode data and function variables that are destructured from the useState call on line 5. This was my first mistake.

The isExpanded property of any node of the tree is used to determine whether a parent node should render its children.

The itemHandler callback is passed as a prop to the <JumpMenu /> component on line 30.

The JumpMenu component then calls the itemHandler callback in a click handler when the user selects a node to zoom in on. The selected node is passed to itemHandler which will set the isExpanded property of the selected node and then setRootNode will be called to re-render the whole tree which will render the selected node’s children or not depending on the isExpanded property.

I had this code in the <JumpMenu /> component:

const nodeSelectHandler = async (
  node: HierarchyNode<StructureItem>,
  e: React.MouseEvent<any>
) => {
  const NodeSelector = `#node-${nextJump.data.id}`
  const nodeIsInDocument = !!document.querySelector(NodeSelector)

  if (nodeIsInDocument) {
    itemHandler(node)
  } else {
    itemHandler(node.parent)

    await until(() => document.querySelectorAll(NodeSelector).length === 1, 200)

    itemHandler(node)
  }

  e.preventDefault()
  e.stopPropagation()
}

Without getting too bogged down in the code, I need to cause a re-render of the tree twice if the selected node is not in the svg document yet, that is if the node’s parent’s isExpanded property is false.

I need to render Once for the parent node to render its children which will include the selected node and another re-render to change the origin to the selected node.

I check if the selected node is in the DOM on line 6.
On line 11, I want to cause a re-render with the parent first. A rerender of the parent will cause it to expand and then render the child nodes. Once the child nodes are in the DOM, I can then change the origin to the child node.

I wait for the node to appear in the DOM on line 13.

line 14 calls the itemHandler again to navigate to the new node.

There be Dragons

The problen I have with this is that the itemHandler in the TreeContainer parent component was created as a closure.

A closure is a combination of a function and a lexical environment within which that function is called.

When itemHandler was created, it is created in it’s own lexical scope that is passed into the JumpMenu component.

When I call itemHandler which in turn calls setRootNode to update the hierarchy and cause the rerender, it is not picked up outside the environment and the rerender does not happen.

Solution

I remembered reading this when I first started looking into hooks.

Don’t call Hooks inside loops, conditions, or nested functions. Instead, always use Hooks at the top level of your React function. By following this rule, you ensure that Hooks are called in the same order each time a component renders. That’s what allows React to correctly preserve the state of Hooks between multiple useState and useEffect calls.

This indirectly led me to the solution.

My solution is to prime a rerender from the click handler of the <JumpMenu /> component.

I can set some internal state in the JumpMenu component that can be picked up in a useEffectLayout hook which will in turn call itemHandler.

useLayoutEffect is identical to useEffect but it fires synchronously after all DOM mutations. useEffect is non-blocking

<JumpMenu /> now looks this now:

export const JumpMenu: React.FC<JumpMenuProps> = ({ itemHandler }) => {
  const [nextJump, setNextJumpNode] = useState(null);

  const nodeSelectHandler = (node: HierarchyNode<StructureItem>, e: React.MouseEvent<any>) => {
    setNextJumpNode(node);

    e.preventDefault();
    e.stopPropagation();
  };

  useLayoutEffect(() => {
    if (!nextJump) {
      return;
    }

    (async () => {
      const NodeSelector = `#node-${nextJump.data.id}`;
      const nodeIsInDocument = !!document.querySelector(NodeSelector);

      if (nodeIsInDocument) {
        itemHandler(nextJump);
      } else {
        const parent = nextJump.parent;

        itemHandler(parent, false);

        await until(() => document.querySelectorAll(NodeSelector).length === 1, 200);

        setNextJumpNode({ ...nextJump });
      }
    })();
}, [nextJump]);

I use useState on line 2 to create a nextJump setter and getter.

line 5 sets the a node as the nextJump.

the useLayoutEffect will be triggered after a rerender but also only if the nextJump value changes as is specified in the array on line 32.

The rest of the code in the useEffectLayout block is the original code from the itemHandler event handler only this time it works because setRootNode will be called outside of the closure.

Epilogue

There is a eslint plugin to keep you on the straight and narrow but the situation is less clear for typescript.

I did find this but nothing yet for the exhaustive deps rule.

I like hooks but it is a mindshift and there are things to look out for. I’ve been very wary of closures from some weird and bad experiences of years ago. Hooks do make closures seem like a plausible idea.

I’ve never been a huge fan of classes in javascript but one thing they are good for is organising functions and properties with hooks we will need to think about how we structure our code. I am going to look at true functional languages to see if I can pick up any tips.

I would love to hear from anyone who has anything to say about this post.


Paul Cowan

Nomadic cattle rustler and inventor of the electric lasso.
Company Website
Follow me on twitter
Contact me for frontend answers.