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.
- 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 persistindeterminate.- Parent click sets or clears all descendant leaves.
indeterminateis 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)
- Leaves are the set. Parents are views of that set.
leafIds(node)cached if the tree is static- MVP: render + leaf toggle + parent toggle + mixed paint
- Ref for
indeterminate, not a Reactindeterminatehope without checking the version - Do not put
indeterminateon 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
indeterminatein JSON state that gets out of syncchecked={true}on mixed so the property does not show- Toggling only the parent’s own id when the parent is not a leaf
- Mutating the
Setin place and passing the same reference toonChange - Using folder ids in the set and leaf ids, then counting both
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
indeterminateproperty through a ref, and I keepcheckedfalse 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.
Related on this site
- File Explorer Tree View
- Nested Comments Thread
- Lifting State Up
- Accessible Forms and Errors
- Controlled vs uncontrolled inputs