How to properly maintain a tree structure parallel to the React component tree

43 Views Asked by At

I'm creating a framework agnostic library for 5-way navigation (navigating via direction keys). I want to have bindings for React and other JS frameworks and I started with React since I expect it to be the most difficult one.

The core of the library is a mutable tree. If a React component wants to participate in the navigation it can use a useNavigationNode(options) hook that should create and register the node with the tree on mount and remove it when the component unmounts.

I ran into problems trying to make it work via useEffect since children effects run before parent ones and I need the parent to exist in the tree before creating the child. I ended up creating the node in render and saving it in a ref if it doesn't already exist and cleaning it up in an effect.

export function useNavigationNode(options: NavigationItemOptions): NodeId {
  const { tree, parentNode } = useNavigationContext();

  const parent = options.parent ?? parentNode;

  // create node in tree on mount
  // its in render so parent nodes get created before children
  // (react runs children effects first)
  const nodeRef = useRef<NavigationItem | null>(null);

  // recreate the node if parent changes
  if (nodeRef.current !== null && nodeRef.current.parent != parent) {
    removeNode(tree, nodeRef.current.id);
    nodeRef.current = null;
  }

  if (nodeRef.current === null) {
    nodeRef.current = createNode(tree, options.id, {
      parent,
      // ...
    });
  } else {
    // update node when options change
  }

  // nodeIds contain parent id so if parent changes the node also gets recreated
  // and the previous  one gets cleaned up
  const nodeId = nodeRef.current.id;

  // cleanup node from tree on unmount
  useEffect(() => {
    return () => {
      removeNode(tree, nodeId);
    };
  }, [tree, nodeId]);
}

I had to handle StrictMode double render/effect so I keep track of how many times the node was inserted/deleted and remove it only when the count reaches 0.

export function createNode(
  tree: NavigationTree,
  localId: NodeId,
  options: NodeOptions,
): NavigationNode {
  const globalId = createGlobalId(options.parent, localId);

  const existingNode = tree.nodes.get(globalId);
  if (existingNode != null) {
    // handle React StrictMode
    existingNode.count += 1;
    const count = existingNode.count;
    if (count > 2)
      console.warn(
        `creating duplicate node: ${globalId}, duplicates: ${count}`,
      );

    return existingNode;
  }

  // create and return new node
}

export function removeNode(tree: NavigationTree, globalId: NodeId) {
  if (globalId === tree.root) {
    throw new Error("cannot remove root node");
  }

  const node = tree.nodes.get(globalId);
  if (node == null) {
    return;
  }

  node.count -= 1;
  if (node.count > 0) {
    return;
  }

  // actually remove the node
}

This seems to work but it feels kinda hacky, it already breaks HMR and I'm not confident it doesn't violate any React rules.

Are there any problems/bugs I'm about to run into? Does anyone have experience with maintaining a tree structure parallel to the React component one or know of a library that does that correctly?

0

There are 0 best solutions below