How I built a nested checkbox React component
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 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
- 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 thefindNode
function. - Set the checked/unchecked status for the node base on the value of the event’s
currentTarget.checked
- Update the checked/unchecked status of the descendants. This logic is implemented in the
toggleDescendants
function. - 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!
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