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