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?