Build a nested checkbox tree where parents and children stay in sync. Toggling a parent cascades down to every descendant; a parent's own state is derived up from its children — checked when all are checked, unchecked when none are, and indeterminate (the dash) when only some are. The clean way to keep this consistent is to store one source of truth — the checked leaves — and derive every folder from them.
type Node = { id: string; label: string; children?: Node[] };
// A self-contained component. No props.
function App(): JSX.Element;
check "Citrus" → Orange ✓ and Lemon ✓ (cascade down)
uncheck "Orange" → Citrus becomes ▣ indeterminate (some, not all)
check "Orange" again → Citrus ✓ (all children checked)
checking all of Apple, Banana, Orange, Lemon → "Fruits" ✓
unchecking any one → "Fruits" ▣ indeterminate
Set of checked leaf ids; folders are derived, never stored.indeterminate is a property. It can't be set via HTML attribute — set the DOM property with a ref.The temptation is to store a checked flag on every node and keep them all in sync by hand. Don't. Store one thing — the set of checked leaves — and derive every folder's state from it. Cascade-down becomes "add/remove all my leaves," and the tri-state up-propagation falls out for free.
Two rules must always hold: check a parent and all its descendants check; and a parent shows checked / indeterminate / unchecked according to its descendants. If you keep a boolean on each node, those rules are two manual sync passes that can drift — toggle a leaf and forget to recompute an ancestor, and the tree lies. But notice the folders carry no independent information: a folder is entirely a function of its leaves. So store only the leaves; compute the folders.
State is a Set<string> of checked leaf ids. For any node, gather its leaf ids and count how many are in the set: zero → unchecked, all → checked, in between → indeterminate. Toggling a node looks at whether all its leaves are currently checked; if so it removes them all, otherwise it adds them all — that's the downward cascade. There's no separate upward step: because folders are derived on every render, ancestors recompute automatically.
The naive version stores a boolean per node and tries to sync both directions:
function toggle(node) {
node.checked = !node.checked;
checkAllChildren(node, node.checked); // down…
updateParents(node); // …and up — easy to get wrong
}
Now every toggle mutates nodes, then walks down, then walks up, and any missed path leaves a parent disagreeing with its children. Indeterminate is especially fiddly because it's neither true nor false. Deriving folders from leaf state deletes both walks: down is a one-line set update, up doesn't exist.
import { useState, useRef, useEffect } from 'react';
import './styles.css';
type Node = { id: string; label: string; children?: Node[] };
const TREE: Node = {
id: 'fruits',
label: 'Fruits',
children: [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{
id: 'citrus',
label: 'Citrus',
children: [
{ id: 'orange', label: 'Orange' },
{ id: 'lemon', label: 'Lemon' },
],
},
],
};
function leafIds(node: Node): string[] {
return node.children ? node.children.flatMap(leafIds) : [node.id];
}
type State = 'checked' | 'unchecked' | 'indeterminate';
function nodeState(node: Node, checked: Set<string>): State {
const leaves = leafIds(node);
const n = leaves.filter((id) => checked.has(id)).length;
if (n === 0) return 'unchecked';
if (n === leaves.length) return 'checked';
return 'indeterminate';
}
function TreeNode({
node,
checked,
toggle,
}: {
node: Node;
checked: Set<string>;
toggle: (node: Node) => void;
}) {
const state = nodeState(node, checked);
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = state === 'indeterminate';
}, [state]);
return (
<li>
<label className="node-label">
<input
ref={ref}
type="checkbox"
checked={state === 'checked'}
onChange={() => toggle(node)}
/>
{node.label}
</label>
{node.children && (
<ul>
{node.children.map((child) => (
<TreeNode key={child.id} node={child} checked={checked} toggle={toggle} />
))}
</ul>
)}
</li>
);
}
export default function App() {
const [checked, setChecked] = useState<Set<string>>(new Set());
function toggle(node: Node) {
const leaves = leafIds(node);
const allChecked = leaves.every((id) => checked.has(id));
const next = new Set(checked);
if (allChecked) leaves.forEach((id) => next.delete(id));
else leaves.forEach((id) => next.add(id));
setChecked(next);
}
return (
<main className="container">
<h1>Nested Checkboxes</h1>
<ul className="tree">
<TreeNode node={TREE} checked={checked} toggle={toggle} />
</ul>
</main>
);
}
leafIds flattens any node to its leaves; nodeState counts how many are checked to return one of three states. toggle cascades down with a single set update — allChecked ? deleteAll : addAll — copying the set first so React re-renders. The checked attribute on the input handles checked/unchecked, but indeterminate isn't an attribute, so an effect writes the indeterminate DOM property whenever state changes. Folders never store anything; nodeState recomputes them from leaves on every render, so the upward rule is automatic.
Checked set empty (everything unchecked).
toggle(citrus): its leaves are ['orange','lemon'], none checked, so allChecked is false → add both. Set is {orange, lemon}. Re-render: nodeState(citrus) = all 2 of 2 → checked; nodeState(fruits) = 2 of 4 → indeterminate (its checkbox shows the dash).toggle(orange): leaf ['orange'], currently checked → remove it. Set is {lemon}. Now citrus = 1 of 2 → indeterminate; fruits = 1 of 4 → indeterminate.{apple, banana, orange, lemon}. Every node = checked.No ancestor was ever updated directly — each render simply re-derived folder states from the one leaf set.
indeterminate as an attribute. <input indeterminate> does nothing. Fix: set the DOM property via a ref/effect.checked.add(id) keeps the reference → no re-render. Fix: new Set(checked) then mutate.leafIds + set ops is simpler and idempotent. Fix: compute leaves, add/remove all.leaves.every(checked) — only "all checked" unchecks.leafIds/nodeState per node so re-renders stay cheap.This version keeps the same leaf-only model but routes every transition through a reducer. It is useful when checkbox changes will later participate in undo, analytics, or shared state.
import { useReducer } from 'react';
import './styles.css';
type Node = { id: string; label: string; children?: Node[] };
const TREE: Node = { id: 'fruits', label: 'Fruits', children: [
{ id: 'apple', label: 'Apple' }, { id: 'banana', label: 'Banana' },
{ id: 'citrus', label: 'Citrus', children: [
{ id: 'orange', label: 'Orange' }, { id: 'lemon', label: 'Lemon' },
] },
] };
const leavesOf = (node: Node): string[] => node.children ? node.children.flatMap(leavesOf) : [node.id];
type BoxState = 'checked' | 'unchecked' | 'indeterminate';
function stateOf(node: Node, selected: Set<string>): BoxState {
const leaves = leavesOf(node);
const count = leaves.filter((id) => selected.has(id)).length;
return count === 0 ? 'unchecked' : count === leaves.length ? 'checked' : 'indeterminate';
}
function reducer(selected: Set<string>, node: Node) {
const leaves = leavesOf(node);
const remove = leaves.every((id) => selected.has(id));
const next = new Set(selected);
leaves.forEach((id) => remove ? next.delete(id) : next.add(id));
return next;
}
function TreeNode({ node, selected, dispatch }: { node: Node; selected: Set<string>; dispatch: (node: Node) => void }) {
const state = stateOf(node, selected);
return <li>
<label className="node-label">
<input type="checkbox" checked={state === 'checked'}
ref={(input) => { if (input) input.indeterminate = state === 'indeterminate'; }}
onChange={() => dispatch(node)} />
{node.label}
</label>
{node.children && <ul>{node.children.map((child) =>
<TreeNode key={child.id} node={child} selected={selected} dispatch={dispatch} />
)}</ul>}
</li>;
}
export default function App() {
const [selected, dispatch] = useReducer(reducer, new Set<string>());
return <main className="container">
<h1>Nested Checkboxes</h1>
<ul className="tree"><TreeNode node={TREE} selected={selected} dispatch={dispatch} /></ul>
</main>;
}An object can be a convenient serializable source of truth. Folder values are still derived, so the representation changes without introducing duplicated parent state.
import { useState } from 'react';
import './styles.css';
type Node = { id: string; label: string; children?: Node[] };
type Selection = Record<string, boolean>;
const TREE: Node = { id: 'fruits', label: 'Fruits', children: [
{ id: 'apple', label: 'Apple' }, { id: 'banana', label: 'Banana' },
{ id: 'citrus', label: 'Citrus', children: [
{ id: 'orange', label: 'Orange' }, { id: 'lemon', label: 'Lemon' },
] },
] };
const leavesOf = (node: Node): string[] => node.children ? node.children.flatMap(leavesOf) : [node.id];
function stateOf(node: Node, values: Selection) {
const leaves = leavesOf(node), count = leaves.filter((id) => values[id]).length;
return { checked: count === leaves.length, mixed: count > 0 && count < leaves.length };
}
function Branch({ node, values, change }: { node: Node; values: Selection; change: (node: Node) => void }) {
const state = stateOf(node, values);
return <li>
<label className="node-label">
<input type="checkbox" checked={state.checked}
ref={(input) => { if (input) input.indeterminate = state.mixed; }}
onChange={() => change(node)} />
{node.label}
</label>
{node.children && <ul>{node.children.map((child) =>
<Branch key={child.id} node={child} values={values} change={change} />
)}</ul>}
</li>;
}
export default function App() {
const [values, setValues] = useState<Selection>({});
function change(node: Node) {
const leaves = leavesOf(node);
const value = !leaves.every((id) => values[id]);
setValues((current) => Object.fromEntries([
...Object.entries(current), ...leaves.map((id) => [id, value]),
]));
}
return <main className="container">
<h1>Nested Checkboxes</h1>
<ul className="tree"><Branch node={TREE} values={values} change={change} /></ul>
</main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a nested checkbox tree where parents and children stay in sync. Toggling a parent cascades down to every descendant; a parent's own state is derived up from its children — checked when all are checked, unchecked when none are, and indeterminate (the dash) when only some are. The clean way to keep this consistent is to store one source of truth — the checked leaves — and derive every folder from them.
type Node = { id: string; label: string; children?: Node[] };
// A self-contained component. No props.
function App(): JSX.Element;
check "Citrus" → Orange ✓ and Lemon ✓ (cascade down)
uncheck "Orange" → Citrus becomes ▣ indeterminate (some, not all)
check "Orange" again → Citrus ✓ (all children checked)
checking all of Apple, Banana, Orange, Lemon → "Fruits" ✓
unchecking any one → "Fruits" ▣ indeterminate
Set of checked leaf ids; folders are derived, never stored.indeterminate is a property. It can't be set via HTML attribute — set the DOM property with a ref.The temptation is to store a checked flag on every node and keep them all in sync by hand. Don't. Store one thing — the set of checked leaves — and derive every folder's state from it. Cascade-down becomes "add/remove all my leaves," and the tri-state up-propagation falls out for free.
Two rules must always hold: check a parent and all its descendants check; and a parent shows checked / indeterminate / unchecked according to its descendants. If you keep a boolean on each node, those rules are two manual sync passes that can drift — toggle a leaf and forget to recompute an ancestor, and the tree lies. But notice the folders carry no independent information: a folder is entirely a function of its leaves. So store only the leaves; compute the folders.
State is a Set<string> of checked leaf ids. For any node, gather its leaf ids and count how many are in the set: zero → unchecked, all → checked, in between → indeterminate. Toggling a node looks at whether all its leaves are currently checked; if so it removes them all, otherwise it adds them all — that's the downward cascade. There's no separate upward step: because folders are derived on every render, ancestors recompute automatically.
The naive version stores a boolean per node and tries to sync both directions:
function toggle(node) {
node.checked = !node.checked;
checkAllChildren(node, node.checked); // down…
updateParents(node); // …and up — easy to get wrong
}
Now every toggle mutates nodes, then walks down, then walks up, and any missed path leaves a parent disagreeing with its children. Indeterminate is especially fiddly because it's neither true nor false. Deriving folders from leaf state deletes both walks: down is a one-line set update, up doesn't exist.
import { useState, useRef, useEffect } from 'react';
import './styles.css';
type Node = { id: string; label: string; children?: Node[] };
const TREE: Node = {
id: 'fruits',
label: 'Fruits',
children: [
{ id: 'apple', label: 'Apple' },
{ id: 'banana', label: 'Banana' },
{
id: 'citrus',
label: 'Citrus',
children: [
{ id: 'orange', label: 'Orange' },
{ id: 'lemon', label: 'Lemon' },
],
},
],
};
function leafIds(node: Node): string[] {
return node.children ? node.children.flatMap(leafIds) : [node.id];
}
type State = 'checked' | 'unchecked' | 'indeterminate';
function nodeState(node: Node, checked: Set<string>): State {
const leaves = leafIds(node);
const n = leaves.filter((id) => checked.has(id)).length;
if (n === 0) return 'unchecked';
if (n === leaves.length) return 'checked';
return 'indeterminate';
}
function TreeNode({
node,
checked,
toggle,
}: {
node: Node;
checked: Set<string>;
toggle: (node: Node) => void;
}) {
const state = nodeState(node, checked);
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = state === 'indeterminate';
}, [state]);
return (
<li>
<label className="node-label">
<input
ref={ref}
type="checkbox"
checked={state === 'checked'}
onChange={() => toggle(node)}
/>
{node.label}
</label>
{node.children && (
<ul>
{node.children.map((child) => (
<TreeNode key={child.id} node={child} checked={checked} toggle={toggle} />
))}
</ul>
)}
</li>
);
}
export default function App() {
const [checked, setChecked] = useState<Set<string>>(new Set());
function toggle(node: Node) {
const leaves = leafIds(node);
const allChecked = leaves.every((id) => checked.has(id));
const next = new Set(checked);
if (allChecked) leaves.forEach((id) => next.delete(id));
else leaves.forEach((id) => next.add(id));
setChecked(next);
}
return (
<main className="container">
<h1>Nested Checkboxes</h1>
<ul className="tree">
<TreeNode node={TREE} checked={checked} toggle={toggle} />
</ul>
</main>
);
}
leafIds flattens any node to its leaves; nodeState counts how many are checked to return one of three states. toggle cascades down with a single set update — allChecked ? deleteAll : addAll — copying the set first so React re-renders. The checked attribute on the input handles checked/unchecked, but indeterminate isn't an attribute, so an effect writes the indeterminate DOM property whenever state changes. Folders never store anything; nodeState recomputes them from leaves on every render, so the upward rule is automatic.
Checked set empty (everything unchecked).
toggle(citrus): its leaves are ['orange','lemon'], none checked, so allChecked is false → add both. Set is {orange, lemon}. Re-render: nodeState(citrus) = all 2 of 2 → checked; nodeState(fruits) = 2 of 4 → indeterminate (its checkbox shows the dash).toggle(orange): leaf ['orange'], currently checked → remove it. Set is {lemon}. Now citrus = 1 of 2 → indeterminate; fruits = 1 of 4 → indeterminate.{apple, banana, orange, lemon}. Every node = checked.No ancestor was ever updated directly — each render simply re-derived folder states from the one leaf set.
indeterminate as an attribute. <input indeterminate> does nothing. Fix: set the DOM property via a ref/effect.checked.add(id) keeps the reference → no re-render. Fix: new Set(checked) then mutate.leafIds + set ops is simpler and idempotent. Fix: compute leaves, add/remove all.leaves.every(checked) — only "all checked" unchecks.leafIds/nodeState per node so re-renders stay cheap.This version keeps the same leaf-only model but routes every transition through a reducer. It is useful when checkbox changes will later participate in undo, analytics, or shared state.
import { useReducer } from 'react';
import './styles.css';
type Node = { id: string; label: string; children?: Node[] };
const TREE: Node = { id: 'fruits', label: 'Fruits', children: [
{ id: 'apple', label: 'Apple' }, { id: 'banana', label: 'Banana' },
{ id: 'citrus', label: 'Citrus', children: [
{ id: 'orange', label: 'Orange' }, { id: 'lemon', label: 'Lemon' },
] },
] };
const leavesOf = (node: Node): string[] => node.children ? node.children.flatMap(leavesOf) : [node.id];
type BoxState = 'checked' | 'unchecked' | 'indeterminate';
function stateOf(node: Node, selected: Set<string>): BoxState {
const leaves = leavesOf(node);
const count = leaves.filter((id) => selected.has(id)).length;
return count === 0 ? 'unchecked' : count === leaves.length ? 'checked' : 'indeterminate';
}
function reducer(selected: Set<string>, node: Node) {
const leaves = leavesOf(node);
const remove = leaves.every((id) => selected.has(id));
const next = new Set(selected);
leaves.forEach((id) => remove ? next.delete(id) : next.add(id));
return next;
}
function TreeNode({ node, selected, dispatch }: { node: Node; selected: Set<string>; dispatch: (node: Node) => void }) {
const state = stateOf(node, selected);
return <li>
<label className="node-label">
<input type="checkbox" checked={state === 'checked'}
ref={(input) => { if (input) input.indeterminate = state === 'indeterminate'; }}
onChange={() => dispatch(node)} />
{node.label}
</label>
{node.children && <ul>{node.children.map((child) =>
<TreeNode key={child.id} node={child} selected={selected} dispatch={dispatch} />
)}</ul>}
</li>;
}
export default function App() {
const [selected, dispatch] = useReducer(reducer, new Set<string>());
return <main className="container">
<h1>Nested Checkboxes</h1>
<ul className="tree"><TreeNode node={TREE} selected={selected} dispatch={dispatch} /></ul>
</main>;
}An object can be a convenient serializable source of truth. Folder values are still derived, so the representation changes without introducing duplicated parent state.
import { useState } from 'react';
import './styles.css';
type Node = { id: string; label: string; children?: Node[] };
type Selection = Record<string, boolean>;
const TREE: Node = { id: 'fruits', label: 'Fruits', children: [
{ id: 'apple', label: 'Apple' }, { id: 'banana', label: 'Banana' },
{ id: 'citrus', label: 'Citrus', children: [
{ id: 'orange', label: 'Orange' }, { id: 'lemon', label: 'Lemon' },
] },
] };
const leavesOf = (node: Node): string[] => node.children ? node.children.flatMap(leavesOf) : [node.id];
function stateOf(node: Node, values: Selection) {
const leaves = leavesOf(node), count = leaves.filter((id) => values[id]).length;
return { checked: count === leaves.length, mixed: count > 0 && count < leaves.length };
}
function Branch({ node, values, change }: { node: Node; values: Selection; change: (node: Node) => void }) {
const state = stateOf(node, values);
return <li>
<label className="node-label">
<input type="checkbox" checked={state.checked}
ref={(input) => { if (input) input.indeterminate = state.mixed; }}
onChange={() => change(node)} />
{node.label}
</label>
{node.children && <ul>{node.children.map((child) =>
<Branch key={child.id} node={child} values={values} change={change} />
)}</ul>}
</li>;
}
export default function App() {
const [values, setValues] = useState<Selection>({});
function change(node: Node) {
const leaves = leavesOf(node);
const value = !leaves.every((id) => values[id]);
setValues((current) => Object.fromEntries([
...Object.entries(current), ...leaves.map((id) => [id, value]),
]));
}
return <main className="container">
<h1>Nested Checkboxes</h1>
<ul className="tree"><Branch node={TREE} values={values} change={change} /></ul>
</main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.