Skip to main content

Implement Nested Checkboxes

Implement a component that renders a nested checkboxes for each item in the Javascript object.

Use Vanilla JS and HTML, no frameworks or libraries.

Don't care about the styling, focus on the functionality.

Example of an Object:

const data = [
{
id: 1,
label: "Fruits",
checked: false,
children: [
{ id: 2, label: "Apple", checked: false },
{ id: 3, label: "Banana", checked: false },
{
id: 4,
label: "Citrus",
checked: false,
children: [
{ id: 5, label: "Orange", checked: false },
{ id: 6, label: "Lemon", checked: false },
],
},
],
},
{
id: 7,
label: "Vegetables",
checked: false,
children: [
{ id: 8, label: "Tomato", checked: false },
{ id: 9, label: "Cucumber", checked: false },
],
},
];

Requirements:

  • The component should be able to handle nested checkboxes.
  • The component should be able to handle the following states: checked, unchecked,indeterminate
  • Selecting a parent checkbox should select all children checkboxes
  • If all child checkboxes are selected, then parent checkbox should be selected
  • If any child is unselected, the parent should be unselected too
  • The structure should be recursive and support any level of nesting

Solution

https://codesandbox.io/p/sandbox/68rvr7?file=%2Findex.html%3A70%2C1

Checkbox Item

class CheckboxItem {
constructor(item) {
this.id = item.id;
this.label = item.label;
this.checked = item.checked || false;
this.children = item.children
? item.children.map((child) => new CheckboxItem(child))
: [];
this.parent = null;

// Set parent references for children
this.children.forEach((child) => (child.parent = this));
}

getState() {
if (this.children.length === 0) {
return this.checked ? "checked" : "unchecked";
}

let checkedCount = 0;
let indeterminateExists = false;

for (const child of this.children) {
const childState = child.getState();
if (childState === "checked") {
checkedCount++;
} else if (childState === "indeterminate") {
indeterminateExists = true;
}
}

if (
indeterminateExists ||
(checkedCount > 0 && checkedCount < this.children.length)
) {
return "indeterminate";
} else if (checkedCount === this.children.length) {
return "checked";
}
return "unchecked";
}

setChecked(isChecked) {
this.checked = isChecked;
// Propagate to children
this.children.forEach((child) => child.setChecked(isChecked));
// Update parent states
if (this.parent) {
this.parent.updateStateFromChildren();
}
}

updateStateFromChildren() {
const state = this.getState();
this.checked = state === "checked";
// Bubble up to parent
if (this.parent) {
this.parent.updateStateFromChildren();
}
}
}

React Implementation

import React, { useState } from "react";

// Initial nested data
const initialData = [
{
id: 1,
label: "Fruits",
checked: false,
children: [
{ id: 2, label: "Apple", checked: false },
{ id: 3, label: "Banana", checked: false },
{
id: 4,
label: "Citrus",
checked: false,
children: [
{ id: 5, label: "Orange", checked: false },
{ id: 6, label: "Lemon", checked: false },
],
},
],
},
{
id: 7,
label: "Vegetables",
checked: false,
children: [
{ id: 8, label: "Tomato", checked: false },
{ id: 9, label: "Cucumber", checked: false },
],
},
];

export default function NestedCheckbox() {
const [tree, setTree] = useState(initialData);

// Update the entire subtree recursively
const setCheckedRecursive = (node, checked) => {
const updated = { ...node, checked };
if (node.children) {
updated.children = node.children.map(child =>
setCheckedRecursive(child, checked)
);
}
return updated;
};

// Find and update a node by ID, and propagate changes up and down
const updateTreeById = (nodes, targetId, checked) => {
return nodes.map(node => {
if (node.id === targetId) {
return setCheckedRecursive(node, checked);
}

if (node.children) {
return {
...node,
children: updateTreeById(node.children, targetId, checked),
};
}

return node;
});
};

// Determine the checkbox status for a node: checked, unchecked, or indeterminate
const calculateStatus = node => {
if (!node.children) {
return { checked: node.checked, indeterminate: false };
}

const childrenStatus = node.children.map(calculateStatus);
const allChecked = childrenStatus.every(s => s.checked && !s.indeterminate);
const someChecked = childrenStatus.some(s => s.checked || s.indeterminate);

return {
checked: allChecked,
indeterminate: !allChecked && someChecked,
};
};

// Recursive rendering of the tree
const renderCheckboxNode = (node) => {
const status = calculateStatus(node);

return (
<div key={node.id} style={{ paddingLeft: 20 }}>
<input
type="checkbox"
checked={status.checked}
ref={(el) => {
if (el) el.indeterminate = status.indeterminate;
}}
onChange={(e) => handleCheck(node.id, e.target.checked)}
/>
<span>{node.label}</span>

{node.children &&
node.children.map(child => renderCheckboxNode(child))}
</div>
);
};

const handleCheck = (id, checked) => {
const updatedTree = updateTreeById(tree, id, checked);
setTree(updatedTree);
};

return <div>{tree.map(node => renderCheckboxNode(node))}</div>;
}