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>;
}