Tree-structured UIs are everywhere — file explorers, comment threads, org charts. They all share the same challenge: a component that renders itself recursively. Getting this right in React is trickier than it looks.
While building BlockTeXu, a block-based visual LaTeX editor, I had to design a recursive component that could represent arbitrarily nested math expressions. Here's what I learned.
Representing Math as a Tree
First, let's look at the data structure. A math expression is a tree where each node is a "block":
interface PlacedBlock {
id: number;
definition: BlockDefinition; // block type (fraction, integral, etc.)
children: PlacedBlock[][]; // 2D array: [slot index][blocks in that slot]
rawValues: string[]; // text typed into empty slots
}
Key design choice: children is a 2D array. A fraction block has two slots (numerator and denominator), and each slot can hold multiple blocks side by side:
Fraction block
children[0] → [block, block, ...] // slot 0: numerator
children[1] → [block, ...] // slot 1: denominator
This lets you express deeply nested structures like $\frac{x + 1}{\int_0^1 f(t) \, dt}$ naturally.
The Recursive Component Design
Two-Component Pattern
I split the UI into two components that call each other:
| Component | Role |
|---|---|
PlacedBlock |
Renders one block. If it has input slots, calls BlockSlot
|
BlockSlot |
Renders one input slot. If it has content, calls PlacedBlock
|
PlacedBlock (Fraction)
├── BlockSlot[0] (Numerator)
│ └── PlacedBlock (Integral)
│ ├── BlockSlot[0] (Lower bound)
│ └── BlockSlot[1] (Upper bound)
└── BlockSlot[1] (Denominator)
└── PlacedBlock (Variable y)
PlacedBlock: Rendering a Single Block
function PlacedBlock({ block, depth = 0, onAddBlockToSlot, ... }) {
const hasInputs = block.definition.inputs > 0;
return (
<span
className={`placed-block ${depth > 0 ? 'nested' : ''}`}
draggable={depth === 0} // only top-level blocks are draggable
>
{hasInputs ? (
// structural block: render slots recursively
<span className="placed-block-structure">
{block.children.map((slotBlocks, slotIdx) => (
<BlockSlot
key={slotIdx}
slotBlocks={slotBlocks}
depth={depth}
parentBlock={block}
slotIndex={slotIdx}
onAddBlockToSlot={onAddBlockToSlot}
/>
))}
</span>
) : (
// leaf block: render math with KaTeX
<span ref={katexRef} className="placed-block-katex" />
)}
</span>
);
}
The key branch:
- Structural blocks (fraction, integral, etc.) → render child slots recursively
- Leaf blocks (variables, operators, etc.) → render directly with KaTeX
BlockSlot: Rendering an Input Slot
function BlockSlot({ slotBlocks, depth, parentBlock, slotIndex, ... }) {
const hasBlocks = slotBlocks.length > 0;
return (
<span className="block-slot">
{hasBlocks && slotBlocks.map((childBlock) => (
<PlacedBlock
key={childBlock.id}
block={childBlock}
depth={depth + 1} // ← increment depth here
onAddBlockToSlot={onAddBlockToSlot}
/>
))}
{hasBlocks ? (
<span className="slot-add-zone">+</span>
) : (
<input
type="text"
placeholder="?"
value={rawValue}
onChange={(e) => onUpdateRawValue(parentBlock.id, slotIndex, e.target.value)}
/>
)}
</span>
);
}
Key Insight: depth + 1 is incremented in BlockSlot, not in PlacedBlock. This means depth always reflects the nesting level of the current block, not the slot. Empty slots show a text input; slots with blocks show the blocks plus an add button.
Controlling Behavior with depth
The depth prop does two important things:
1. Drag & Drop Control
draggable={depth === 0}
Only top-level blocks are draggable for reordering. If nested blocks were also draggable, dragging them would conflict with the parent block's drag handler — you'd get both moving at once.
2. Visual Scaling via CSS
Without size reduction at each nesting level, deeply nested math would expand the UI horizontally until it overflows.
.placed-block {
padding: 6px 10px;
font-size: 0.95rem;
background: #1a365d;
}
.placed-block.nested {
padding: 3px 6px;
font-size: 0.85rem;
background: #2a4a7f;
box-shadow: none;
}
For finer-grained control, you could pass depth as a CSS custom property:
// potential future approach
<span style={{ '--depth': depth } as React.CSSProperties}>
Immutable Tree Updates
Rendering the recursive tree is the easy part. Updating it is where things get tricky.
When a user drops a block into a deeply nested slot, you need to update a node at an arbitrary depth — while keeping React's immutability guarantees. No array.push(), no direct mutation.
updateBlockInTree
function updateBlockInTree(
blocks: PlacedBlock[],
id: number,
updater: (b: PlacedBlock) => PlacedBlock
): PlacedBlock[] {
return blocks.map(block => {
// found the target: apply the update
if (block.id === id) {
return updater(block);
}
// recursively search children
const newChildren = block.children.map(slot =>
updateBlockInTree(slot, id, updater)
);
// only create a new object if something actually changed
if (newChildren.some((slot, i) => slot !== block.children[i])) {
return { ...block, children: newChildren };
}
// no change: return the original reference
return block;
});
}
Three things worth noting:
1. Preserve reference identity
return block; // same reference = React skips re-rendering this subtree
Unchanged subtrees return their original object reference. React's reconciler uses Object.is comparison, so unchanged parts skip re-rendering automatically.
2. Separate traversal from update logic
// add a block to a slot
updateBlockInTree(blocks, parentId, parent => {
const newChildren = [...parent.children];
newChildren[slotIndex] = [...newChildren[slotIndex], newBlock];
return { ...parent, children: newChildren };
});
// update a raw text value
updateBlockInTree(blocks, blockId, block => {
const newRawValues = [...block.rawValues];
newRawValues[slotIndex] = value;
return { ...block, rawValues: newRawValues };
});
The updater callback keeps tree traversal logic separate from what you're actually doing. Add, remove, update text — all use the same traversal function.
3. 2D array immutable update requires two spreads
// ❌ mutates in place
parent.children[slotIndex].push(newBlock);
// ✅ creates new arrays at both levels
const newChildren = [...parent.children];
newChildren[slotIndex] = [...newChildren[slotIndex], newBlock];
return { ...parent, children: newChildren };
Since children is PlacedBlock[][], you need to spread both the outer array (slot list) and the inner array (blocks within a slot).
Undo/Redo with a History Stack
Immutable state makes Undo/Redo almost embarrassingly simple:
const [state, setState] = useState<WorkspaceState>({ blocks: [] });
const [history, setHistory] = useState<WorkspaceState[]>([]);
const [future, setFuture] = useState<WorkspaceState[]>([]);
On every state change: push to history
function pushHistory(prev: WorkspaceState) {
setHistory(h => [...h, prev]);
setFuture([]); // clear redo stack on new action
}
const addBlock = (def: BlockDefinition) => {
pushHistory(state);
setState(prev => ({ blocks: [...prev.blocks, createBlock(def)] }));
};
Undo: pop history, push current to future
const undo = useCallback(() => {
if (history.length === 0) return;
const prev = history[history.length - 1];
setHistory(h => h.slice(0, -1));
setFuture(f => [...f, state]);
setState(prev);
}, [history, state]);
Redo: pop future, push current to history
const redo = useCallback(() => {
if (future.length === 0) return;
const next = future[future.length - 1];
setFuture(f => f.slice(0, -1));
setHistory(h => [...h, state]);
setState(next);
}, [future, state]);
Because state snapshots are immutable, the objects stored in history are never accidentally modified. If you used array.push() to mutate state directly, your history would contain references to objects that have since changed — and Undo would restore the wrong values.
LaTeX Generation: Another Recursive Pass
There's a second recursion running over the same tree: generating the LaTeX string.
function generateBlockLatex(block: PlacedBlock): string {
let latex = block.definition.latex; // e.g., "\\frac{□}{□}"
for (let i = 0; i < block.definition.inputs; i++) {
const slotBlocks = block.children[i] || [];
const slotLatex = slotBlocks.length > 0
? slotBlocks.map(b => generateBlockLatex(b)).join(' ')
: block.rawValues[i] || '\\square';
latex = latex.replace('□', slotLatex);
}
return latex;
}
Each block's definition.latex is a template (e.g., \frac{□}{□}). The placeholder □ gets replaced sequentially with the results from child nodes. Because the templates guarantee syntactic correctness, users can never produce a LaTeX syntax error by combining blocks.
The same tree data drives two separate recursive processes: UI rendering and LaTeX generation.
Design Decisions Summary
| Element | Decision | Reason |
|---|---|---|
| Component split |
PlacedBlock + BlockSlot as two layers |
Slots serve as the natural recursion boundary |
| Depth tracking | Integer prop passed down | Controls drag behavior and CSS scaling |
| Children structure |
PlacedBlock[][] (2D array) |
Multiple slots × multiple blocks per slot |
| State updates |
updateBlockInTree recursive walk |
Immutable update while preserving reference identity |
| Undo/Redo |
history / future arrays |
Free snapshot storage from immutable state |
The hardest part of recursive components isn't the rendering — it's immutable updates at arbitrary depth. The updateBlockInTree pattern here applies to any recursive structure: file trees, comment threads, nested forms.
Wrapping Up
If you're building any tree-structured UI in React, the pattern covered here — two mutually-recursive components, depth-based behavior control, and updateBlockInTree for immutable updates — gives you a solid foundation.
I'd love to hear how you've handled recursive UIs in your own projects. Drop a comment below!
Next post: The stale closure problem in keyboard handlers — why useEffect + addEventListener reads stale state, and how useRef fixes it.
Try BlockTeXu at blocktexu.com/en

Top comments (0)