How I built a nested checkbox React component

Sean
SLTC — Sean Learns To Code
5 min readFeb 15, 2023

A while ago I was asked the following question:

Given a JavaScript object like this

const dataOne = {
foo: true,
bar: false,
foobar: {
hello: false,
hi: false,
greetings: {
tom: false
}
}
}

Write code in React (and HTML and CSS if needed) to render a checkbox that can be nested with any number of levels based on the given JavaScript object. Below is an the corresponding checkbox in plaintext

[X] foo
[ ] bar
[ ] foobar
[ ] hello
[ ] hi
[ ] greetings
[ ] tom

There are 3 extra requirements that make the problem a bit more challenging to solve:

1. If a checkbox is checked / unchecked, all of its descendants should become checked / unchecked.

2. If all descendants of a checkbox are checked, that checkbox should be checked

3. If any of the descendants of a checkbox is unchecked, that checkbox should also be unchecked

This question has been piquing my interest for a long time. Last weekend I finally got the chance to set aside some time to work on it.

If you’re not patient with reading the whole content below, feel free to just checkout the code at this link: https://playcode.io/1193701

First attempt with a naive approach

The recursive nature of the input data is a hint of how we should create the React component. We can write the code for the React component like this

const NestedCheckbox = ({ data, ancestors }) => {
const prefix = ancestors.join(".");
return (
<ul>
{
Object.keys(data).map((label) => {
const value = data[label];
const id = `${prefix}.${label}`;
let children = null;
if (typeof value !== "boolean") {
children = <NestedCheckbox data={value} ancestors={[...ancestors, label]} />
}

const checked = value === true;
return (
<li key={id}>
<input type="checkbox" name={id} checked={checked} onChange={(e) => {}}/>
<label htmlFor={id}>{label}</label>
{children}
</li>
)
})
}
</ul>
)
}

And the output looks just right!

The nested checkboxes we want to build

The problem with this approach is it’s a hard to update the app state based on the input data in the current format. The reason is because, if we categorize the output above into leaf and non-leaf checkboxes, then the input data doesn’t store the checked/unchecked state of the non-leaf checkboxes anywhere. As a result, whenever we need to re-render the checked/unchecked state of a non-left checkbox, we don’t know where to look for that value.

Since we cannot rely on the input data in the current format, we need to transform it into a format that includes the checked/unchecked states for non-leaf checkboxes.

Second attempt with some data transformation logic

We can convert the input data above into the following format

const nodes = [
{
label: "foo",
checked: true,
childrenNodes: [],
},
{
label: "bar",
checked: false,
childrenNodes: [],
},
{
label: "foobar",
checked: false,
childrenNodes: [
{
label: "hello",
checked: true,
childrenNodes: [],
parent: "<point to foobar>",
},
{
label: "hello",
checked: true,
childrenNodes: [],
parent: "<point to foobar>",
},
{
label: "greetings",
checked: false,
childrenNodes: [
{
label: "tom",
checked: true,
childrenNodes: [],
parent: "<point to greetings>"
}
],
}
],
},
];

I leave out the code that handles the data transformation from this post but you can find it at https://playcode.io/1193701. Once we have the data ready in the new format, we can change the code of the React component

const NestedCheckbox = ({ data }) => {
const initialNodes = transform(data);
const [ nodes, setNodes ] = useState(initialNodes);

const handleBoxChecked = (e, ancestors) => {
// TODO
}

return (
<NestedCheckboxHelper nodes={nodes} ancestors={[]} onBoxChecked={handleBoxChecked}/>
);
}

const NestedCheckboxHelper = ({ nodes, ancestors, onBoxChecked }) => {
const prefix = ancestors.join(".");
return (
<ul>
{
nodes.map(({ label, checked, childrenNodes }) => {
const id = `${prefix}.${label}`;
let children = null;
if (childrenNodes.length > 0) {
children = <NestedCheckboxHelper nodes={childrenNodes} ancestors={[...ancestors, label]} onBoxChecked={onBoxChecked} />
}

return (
<li key={id}>
<input type="checkbox" name={id} value={label} checked={checked} onChange={(e) => onBoxChecked(e, ancestors)}/>
<label htmlFor={id}>{label}</label>
{children}
</li>
)
})
}
</ul>
)
}

Note that we use the transformed data as the app state and also add an event handler whose logic we will soon implement. The UI output should look the same as before.

Implement the onChange handler

When a checkbox is checked/unchecked, we have to do the following

  1. From the event object, find a reference to the corresponding node in the app state. Because we have the list of ancestors , we can just traverse from the root of the app state to the target node. This logic is implemented in the findNode function.
  2. Set the checked/unchecked status for the node base on the value of the event’s currentTarget.checked
  3. Update the checked/unchecked status of the descendants. This logic is implemented in the toggleDescendants function.
  4. Update the checked/unchecked status of the ancestors. This logic is implemented in the updateAncestors function.

You can check https://playcode.io/1193701 for the code of those functions. The body of handleBoxChecked will look like this

const handleBoxChecked = (e, ancestors) => {
const checked = e.currentTarget.checked;
const node = findNode(nodes, e.currentTarget.value, ancestors);

node.checked = checked;
toggleDescendants(node);
updateAncestors(node);

setNodes(cloneDeep(nodes));
}

And here we are!

The nested checkboxes in action

Performance

In terms of runtime complexity, every time we handle a change event for a checkbox we have to visit all its descendants and potentially all of its ancestors. This is not really an issue because the call to lodash ’s cloneDeep to clone the entire app state is probably already more expensive than the actions of going up and down the tree of nodes.

I don’t know if it’s possible to get away from having to do a deep clone on entire app state. However, one thing that I believe we can work on to improve performance is to use React’s memo to make sure that components whose props are not changed won’t be re-rendered unnecessarily. I guess my next steps for this exercise is to

  • Write code to generate input data of larger size and measure the performance of the current approach
  • Use memo to improve performance

--

--