Solution
Live nested checkboxes demo on CodeSandbox
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>;
}