React Checked Tree
What a test, Checked Tree Recursion!
Kudos here for the inspiration!
Just take me to the Code
I knew this was going to be hard. Nothing worth doing is easy, is it? The first step in figuring this out is what the heck is going on.
Well, we need a tree rendered first. We also need checkboxes. We then need to be able to check the boxes and have the entire tree triggered, but only up the tree. When you click the root parent of the tree the rest of the children are unchecked.
Additionally, we need to be able to search for items in the tree ideally filtering the tree down to just those items...
This sounds like a perfect case for recursion. Lets start with building the tree, I am imagining this is a response from an API in this case
1const response = [
2 { id: 4, parentId: 3, name: 'Days' },
3 { id: 1, parentId: null, name: 'Years' },
4 { id: 2, parentId: 1, name: 'Months' },
5 { id: 5, parentId: null, name: 'Stars' },
6 { id: 3, parentId: 2, name: 'Weeks' },
7 { id: 6, parentId: 5, name: 'Sun' },
8 { id: 7, parentId: 5, name: 'Proxima Centauri' },
9 { id: 8, parentId: null, name: 'Dogs' },
10]
1const buildTree = (response) => {
2 const tree = [];
3 const parent = {};
4
5 for (let i = 0; i < response.length; i++) {
6 parent[response[i].id] = response[i];
7 parent[response[i].id].children = [];
8 parent[response[i].id].isChecked = false;
9 }
10
11 for (let i = 0; i < response.length; i++) {
12 if (response[i].parentId !== null) {
13 parent[response[i].parentId].children.push(response[i]);
14 } else {
15 tree.push(response[i]);
16 }
17 }
18
19 return tree;
20};
21
22
1[
2 {
3 "id": 1,
4 "parentId": null,
5 "name": "Years",
6 "children": [
7 {
8 "id": 2,
9 "parentId": 1,
10 "name": "Months",
11 "children": [
12 {
13 "id": 3,
14 "parentId": 2,
15 "name": "Weeks",
16 "children": [
17 {
18 "id": 4,
19 "parentId": 3,
20 "name": "Days",
21 "children": [],
22 "isChecked": false
23 }
24 ],
25 "isChecked": false
26 }
27 ],
28 "isChecked": false
29 }
30 ],
31 "isChecked": false
32 },
33 {
34 ...
So this seems like it will work. Well, now we can map over this data and render some components. I am going to paste a large chunk of code here and I will do my best to discuss the inner workings, so bear with me.
Recursion
1import { useState } from "react";
2import PropTypes from "prop-types";
3
4export default function CheckedTree({ initData, toggleChecked }) {
5 return (
6 <div className="checked-tree-container">
7 {initData.map((item) => {
8 return (
9 <ItemInCheckbox
10 key={item.id}
11 singleChild={item}
12 isChecked={item.isChecked}
13 toggleChecked={toggleChecked}
14 />
15 );
16 })}
17 </div>
18 );
19}
20
21const ItemInCheckbox = ({ singleChild, toggleChecked }) => {
22 const [open, setOpen] = useState(false);
23
24 return (
25 <div className={`checkbox-row-${singleChild.name.toLowerCase()}`}>
26 <div style={{ display: "flex" }}>
27 <div className="toggle-arrow" onClick={() => setOpen((prev) => !prev)}>
28 {singleChild.children.length > 0 ? (open ? "V" : '>') : "--"}
29 </div>
30 <input
31 type="checkbox"
32 name={singleChild.name}
33 checked={singleChild.isChecked}
34 onChange={() => {
35 toggleChecked(singleChild);
36 }}
37 ></input>
38 <label htmlFor={singleChild.name}> {singleChild.name}</label>
39 </div>
40 {open &&
41 singleChild.children.map((child) => (
42 <div
43 className={`nested-child ${child.name.toLowerCase()}`}
44 key={child.id}
45 >
46 <ItemInCheckbox
47 singleChild={child}
48 isChecked={child.isChecked}
49 toggleChecked={toggleChecked}
50 />
51 </div>
52 ))}
53 </div>
54 );
55};
56CheckedTree.propTypes = {
57 initData: PropTypes.array.isRequired,
58 toggleChecked: PropTypes.func,
59};
60
61ItemInCheckbox.propTypes = {
62 singleChild: PropTypes.shape({
63 id: PropTypes.number.isRequired,
64 name: PropTypes.string.isRequired,
65 parentId: PropTypes.number,
66 isChecked: PropTypes.bool.isRequired,
67 children: PropTypes.array,
68 }).isRequired,
69 toggleChecked: PropTypes.func,
70};
71
So we can ignore the `propTypes` and the `toggleChecked` function. Let's focus on the basic structure. The thought here is to have each checkbox toggle its own state, and this will depend on the global state of the initial tree passed in. This does have a downside as it will cause a re-render every time the tree data changes, even though we are only changing one element of the tree...likely a better way here. Also, each checkbox handles its own styling and its own toggle button. I am just using a ">" and a "v" and then a "--" when there are no children. Of course, the important part here is that each child renders itself based on the children! Recursion! Yay! I assume there is a performance downside here, but realistically this is the simplest way I could think of. This also has the added benefit that the class names will be applied across all children.
Now let's take a look at the `toggleChecked` function which is passed in from the App level.
Let me write this out in more detail here.
We need to make a new pointer so React will re-render the data. So we first copy the initial tree data. Then we call the toggleParents function. This function loops over the tree. If the `id` is the same as the clicked-on `id` then we toggle the state of the `.isChecked` property. We also need to see if there are children, and for each child, we need to toggle the children, this means we need to set each child to false as clicking the top level of a checkedTree needs to reset all the children. When that is done we return true. However, if the `id` doesn't match the clicked-on `id` and there are children, we need to do the same logic on each of the children if that returns true, which it will at a certain point also need to set the item we are currently iterating .isChecked to true and return true!
Return false otherwise...yikes.
1export const toggleParents = (tree, targetId) => {
2 const toggleChildren = (node) => {
3 if (!node.parentId) return;
4 node.isChecked = false;
5 node.children.forEach(toggleChildren);
6 };
7
8 for (const item of tree) {
9 if (item.id === targetId) {
10 item.isChecked = !item.isChecked;
11 if (item.children.length > 0) {
12 item.children.forEach(toggleChildren);
13 }
14 return true;
15 }
16 if (item.children) {
17 const found = toggleParents(item.children, targetId);
18 if (found) {
19 item.isChecked = true;
20 return true;
21 }
22 }
23 }
24 return false;
25};
That is quite the mouthful, ok let's move on. Filtering is actually not too bad, we can use a recursive search...whenever there is an onChange event.
Filter
1export const filterTree = (str, tree) => {
2 const sanitized = str.toLowerCase().trim();
3
4 return tree.filter ((item) => {
5 const found = item.name.toLowerCase().trim().includes(sanitized)
6 if (found) return item
7
8 if (item.children.length > 0) {
9 return filterTree(sanitized, item.children).length > 0
10 }
11 })
12};
13
Again there is probably a better way, as this won't eliminate the rest of the tree rendering. For example, if you search for months, you still get the array which begins at the root of Years. But that is probably ok, given if you are searching a nested tree it will be good to know the parent of that element. Speaking of which I did write a function to find the root parent given a node, but I am not including it as it is not used in the solution. Check out the Github repo for more.
For reference here is the `App.jsx`
1import "./App.css";
2import { useState } from "react";
3import CheckedTree from "./CheckedTree";
4import { toggleParents, treeData, filterTree } from "./helpers";
5
6const Main = () => {
7 const [initData, setInitData] = useState(treeData);
8
9 const toggleChecked = (singleChild) => {
10 const newTree = [...initData];
11 toggleParents(newTree, singleChild.id);
12 setInitData(newTree);
13 };
14
15
16 const filter = (e) => {
17 const searchString = e.target.value;
18 const tree = filterTree(searchString, treeData);
19 setInitData(tree);
20 };
21
22 return (
23 <div className="container">
24 <h1>Mock Form</h1>
25 <input onChange={filter} placeholder="Search..." className="common" />
26 <CheckedTree initData={initData} toggleChecked={toggleChecked} />
27 <button onClick={null} className="common">
28 Submit
29 </button>
30 </div>
31 );
32};
33
34export default Main;
Thanks for reading! This was a hard one. There are a number of things I would change in the future. For example, it would be great to break out the checkbox tree into more components, as it stands now it is quite messy and really has two sections, the recursion and the actual component. Instead of conditionally rendering the 'open...' on the open state it would be nice to have that logic in its own component.
I would also like to make the filtering logic perform better and actually return only the tree elements up until the searched item and nothing more!
And likely more!
Here is my finished demo: