ESC

Type to search the knowledge base.

Nested Checkboxes

Tree checkboxes with parent-child sync: check all descendants, indeterminate parents, and a data model that does not store UI-only flags twice.

advanced7 min read
  • machine-coding
  • interview
  • a11y
  • react

The usual mess is a tree of objects with checked: boolean and indeterminate: boolean on every node. You toggle a leaf, then walk up setting flags, then walk down, then a parent is both checked and mixed. Form state and UI state have fused, and the next click fights the last write.

Nested checkboxes have three visible states: checked, unchecked, mixed. Only leaf membership is source of truth. Parent checked means every descendant leaf is in the set. Mixed means some, not all. Mixed is derived. The DOM indeterminate property is a paint hook you set through a ref, not a second copy of the set.

Key takeaways

  • Store Set<leafId> (or a map of leaf id → boolean). Do not persist indeterminate.
  • Parent click sets or clears all descendant leaves.
  • indeterminate is a DOM property. Set it on the input via ref after render.
  • aria-checked="mixed" if you build a custom checkbox. Native inputs already expose mixed via the property.
  • Walk the tree twice in your head: leaves of a node, then status from those leaves.

Checked, unchecked, and mixed are three states

A native checkbox is only ever checked or not as a form value. Mixed is extra chrome: input.indeterminate = true. React will not keep that in onChange as a third boolean you can put in JSON. If you try to store it, you now have two sources that disagree after a leaf flips.

type Node = {
  id: string;
  label: string;
  children?: Node[];
};

function isLeaf(node: Node) {
  return !node.children || node.children.length === 0;
}

function leafIds(node: Node): string[] {
  if (isLeaf(node)) return [node.id];
  return node.children!.flatMap(leafIds);
}

type CheckState = "checked" | "unchecked" | "mixed";

function checkState(node: Node, checked: Set<string>): CheckState {
  const ids = leafIds(node);
  let on = 0;
  for (const id of ids) if (checked.has(id)) on += 1;
  if (on === 0) return "unchecked";
  if (on === ids.length) return "checked";
  return "mixed";
}

function toggle(node: Node, checked: Set<string>): Set<string> {
  const ids = leafIds(node);
  const allOn = ids.every((id) => checked.has(id));
  const next = new Set(checked);
  for (const id of ids) {
    if (allOn) next.delete(id);
    else next.add(id);
  }
  return next;
}

Toggle rule: if every descendant leaf is checked, uncheck them all. Otherwise check them all. That matches “click a mixed parent to select everything,” which is what people expect from OS trees. Confirm if they want mixed → unchecked instead. Say the rule out loud.

Do not store checked on folders unless the product has folder-only nodes with no leaves. In the interview tree, folders are aggregations.

Problem statement

Render a checkbox tree from nested data. Checking a parent checks all descendants. Unchecking clears them. A parent with a partial selection looks mixed (indeterminate). Leaf clicks bubble up as derived parent states. Keyboard and names must work.

Requirements

Must have

  • Render arbitrary depth from a tree
  • Parent/child sync in both directions (derive up, write down)
  • Indeterminate parents when some descendants are checked
  • Click parent toggles all descendant leaves
  • Accessible names per checkbox
  • Controlled checkedIds + onChange

Should have

  • Expand/collapse folders (separate from checked)
  • Select all / none at the root
  • Count of selected leaves

Nice to have

  • Search / filter that does not drop checked ids
  • Virtualize large trees
  • Persist expansion

Planning (5 minutes out loud)

  1. Leaves are the set. Parents are views of that set.
  2. leafIds(node) cached if the tree is static
  3. MVP: render + leaf toggle + parent toggle + mixed paint
  4. Ref for indeterminate, not a React indeterminate hope without checking the version
  5. Do not put indeterminate on the data objects

Architecture

CheckboxTree
└── TreeRow (recursive)
    ├── Native input + label
    └── children

API

type CheckboxTreeProps = {
  tree: Node[];
  checkedIds: Set<string>;
  onChange: (next: Set<string>) => void;
};

Keep the set in the parent. Recursion only reads it. If you clone the whole tree on every click to flip booleans, you will drop a child.

Implementation sketch

function TreeRow({
  node,
  checked,
  onChange,
}: {
  node: Node;
  checked: Set<string>;
  onChange: (next: Set<string>) => void;
}) {
  const state = checkState(node, checked);
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (inputRef.current) {
      inputRef.current.indeterminate = state === "mixed";
    }
  }, [state]);

  return (
    <li>
      <label>
        <input
          ref={inputRef}
          type="checkbox"
          checked={state === "checked"}
          onChange={() => onChange(toggle(node, checked))}
        />
        {node.label}
      </label>
      {node.children && node.children.length > 0 && (
        <ul>
          {node.children.map((child) => (
            <TreeRow
              key={child.id}
              node={child}
              checked={checked}
              onChange={onChange}
            />
          ))}
        </ul>
      )}
    </li>
  );
}

function CheckboxTree({ tree, checkedIds, onChange }: CheckboxTreeProps) {
  return (
    <ul>
      {tree.map((node) => (
        <TreeRow
          key={node.id}
          node={node}
          checked={checkedIds}
          onChange={onChange}
        />
      ))}
    </ul>
  );
}

checked={state === "checked"} is correct: mixed is not checked. The effect then sets indeterminate. Order matters. If you set checked={true} for mixed, some browsers ignore indeterminate.

React may apply indeterminate as a prop on <input> in current versions. Interviewers still like the ref: it shows you know it is a property, not an HTML attribute, and it works if the prop is stripped. Use the ref. Mention the prop as a shortcut.

Ids must be unique across the whole tree. Two “Documents” folders with the same id will check each other.

For a custom (non-native) checkbox, use role="checkbox" and aria-checked={state === "mixed" ? "mixed" : state === "checked"}. Space toggles. You then own focus styles. Prefer native in a 60 minute round.

Accessibility essentials

Piece Approach
Control Native <input type="checkbox"> + <label>
Mixed indeterminate property; AT maps it to mixed
Custom `aria-checked=“true”
Structure Nested lists, not a fake tree unless you also implement tree keyboard
Name Label text, not aria-label that duplicates it

This is a nested list of checkboxes, not APG tree view, unless they ask for arrow-key tree navigation. Do not add role="tree" without the rest of the pattern.

Interview scoring

Signal What they want
Model One set of leaf ids. Mixed is derived
Write path Parent click writes leaves, never a stored mixed flag
Paint Indeterminate via ref after commit
Recursion Stable keys, unique ids
A11y Real labels, three-state announced
Talk You refuse to store UI-only flags twice

They will uncheck one deep leaf and watch the ancestors. If a grandparent stays fully checked, the derivation is wrong. They may also click a mixed parent and expect all-on.

Footguns

  1. indeterminate in JSON state that gets out of sync
  2. checked={true} on mixed so the property does not show
  3. Toggling only the parent’s own id when the parent is not a leaf
  4. Mutating the Set in place and passing the same reference to onChange
  5. Using folder ids in the set and leaf ids, then counting both
  6. role="tree" with none of the keyboard

Interview out-loud answer

I would keep a set of checked leaf ids and derive every parent. A node is checked when all descendant leaves are in the set, mixed when some are, unchecked when none are. Clicking a node writes the whole descendant leaf list on or off. Mixed is not stored. I paint it with the input’s indeterminate property through a ref, and I keep checked false while mixed. Native labels, nested lists. If they want tree-arrow keyboard, that is a second pattern on top, not a substitute for the set.

Further reading