README
react-immutable-tree
An immutable tree structure because it's very difficult to correctly handle
tree-like data in React. Despite the name, this can be used as a standalone
library, or even (theoretically) with other JS frameworks. It's just called
this because immutable-tree
was already taken on npm (whoops!)
An ImmutableTree
is a tree structure that can have any number of ordered
children. (Note: it's not a good fit for binary tree data where right/left child
relationships matter, because deleting the first child in a node shifts the
second child to the first position.) It is a subclass of EventTarget
, which
makes it easy to subscribe to changes.
When a node changes, its ancestors are all replaced, but its siblings
and children are not (the siblings and children's .parent
properties change,
but it's the same object). Given the following graph (adapted from
here),
updating the purple node causes that node, as well as all green nodes, to be
replaced. Orange nodes are reused.
That makes this library compatible with things like React.memo()
, which use a
simple equality check to decide whether to re-render. Simply subscribe to changes to the tree and grab the new root object when those changes occur.
Getting started
Installation/Importing
To install, run
npm install react-immutable-tree@VERSION
...where VERSION
is the version you'd like to use. Pinning to a specific
version because, until version 1.0.0 is released, the API is not guaranteed to
be stable.
If you're using it on a frontend, you can import the UMD packages from unpkg:
<script crossorigin src="https://unpkg.com/react-immutable-tree@VERSION/dist/react-immutable-tree.umd.js"></script>
<script crossorigin src="https://unpkg.com/react-immutable-tree@VERSION/dist/react-immutable-tree-hook.umd.js"></script>
Your imports might look like this:
import { ImmutableTree } from 'react-immutable-tree';
import { useTree } from 'react-immutable-tree/hook'
If you're using TypeScript, a bunch of handy types are also exported. Check out
the docs for react-immutable-tree
and
react-immutable-tree/hook
for a full
list.
Constructing your tree the easy way
...that is, from JSON data. ImmutableTree
provides a helper method, deserialize
,
which will construct a tree out of your tree-like data. Just provide:
- The object representing your root node
- A function that, given an object representing a node, returns
{ data, children }
(the data object you want associated with the node in the ImmutableTree, and an array of JSON data objects representing children yet to be parsed). This function is not required if your data is already in{ data, children }
form (which is the default output oftree.serialize()
).
import { ImmutableTree } from 'react-immutable-tree';
// Deserializer to convert my JSON data to { data, children } format
// let's say our data looks like { firstName: 'John', lastName: 'Doe', friends: [ ...more people... ] }
function jsonDataToTree(jsonNode) {
return {
data: { firstName: jsonNode.firstName, lastName: jsonNode.lastName },
children: jsonNode.friends,
}
}
const myTree = ImmutableTree.deserialize(JSON.parse(" (data) "), jsonDataToTree);
// ...do things with the tree...
// Serializer to turn treeNode data back into our JSON format
// This function is unnecessary if our preferred format is { children, data }
function treeNodeToJsonData(data, children) {
return {
firstName: data.firstName,
lastName: data.lastName,
friends: children,
};
}
const serialized = myTree.serialize(treeNodeToJsonData);
Using in a React app
const NodeView = React.memo(({ node }) => (
<li>
{node.data.counter}
<Button onClick={() => node.remove()}>Delete this node</Button>
<Button onClick={() => node.setData({ counter: 0 })}>Reset this node</Button>
<Button onClick={() => node.updateData(oldData => ({ counter: oldData.counter + 1 }))}>Increment this node</Button>
<ul>
{node.children.map(child => (
<NodeView node={child} key={child.data.id}/>
))}
</ul>
</li>
));
import { useTree } from 'react-immutable-tree/hook';
const App = ({tree}) => {
const rootNode = useTree(tree);
return (
<ul>
<NodeView node={rootNode}/>
</ul>
);
};
ReactDOM.render(<App tree={myTree} />, document.getElementById('app'));
useTree
can also accept a "tree generator" function: a function that runs
once to initialize the tree.
import { ImmutableTree } from 'react-immutable-tree';
import { useTree } from 'react-immutable-tree/hook';
const App = () => {
const [rootNode, tree] = useTree(() => {
return ImmutableTree.deserialize(MY_SERIALIZED_DATA);
// or anything else required to build the tree, as long as an ImmutableTree is returned
});
return (
<ul>
<NodeView node={rootNode}/>
</ul>
);
};
ReactDOM.render(<App />, document.getElementById('app'));
Constructing/modifying your tree the harder way (manually)
import { ImmutableTree } from 'react-immutable-tree';
const tableOfContents = new ImmutableTree();
tableOfContents.addRootWithData({ title: null });
// If we don't need the tree to behave immutably yet, the easiest way to build it is using this function
const root = tableOfContents.dangerouslyMutablyAddRootWithData({ title: null });
root.dangerouslyMutablyInsertChildWithData({ title: '1. How I did it' });
root.dangerouslyMutablyInsertChildWithData({ title: '3. Why I did it' });
root.dangerouslyMutablyInsertChildWithData({ title: '2. If I did it' }, 1); // optional second argument is index
// That's because most other functions cause the nodes to replace themselves, dispatch events, etc.
// And since they behave that way, we often have to walk the entire tree for subsequent operations
tableOfContents.root.children[0].insertChildWithData({ title: '1.1. How' });
tableOfContents.root.children[0].insertChildWithData({ title: '1.3. did' }); // root is a different object now!
tableOfContents.root.children[0].insertChildWithData({ title: '1.2. I' }, 1); // again, optional second argument is index
tableOfContents.root.children[0].children[2].remove(2);
// HOWEVER, many functions return the updated version of the node you're operating on, making it easy to keep working with the same node
let myNode = tableOfContents.root.children[0];
myNode = myNode.updateData(oldData => { ...oldData, title: 'my very exciting title' });
myNode = myNode.insertChildWithData({ title: 'my even more exciting title' });
myNode = myNode.moveTo(someOtherNode, 2); // No new node is generated for this one, it returns itself. Also, optional second arg is index
myNode = myNode.remove(); // Same here
API Refernce
Check out the Docs!
Running tests
Running the test suite requires Node 15 because Node only recently added
EventTarget
and I don't want to polyfill it just for the tests.