DEV Community

KernelX
KernelX

Posted on

How I Built Recursive UI for a Visual Math Editor in React

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.

LaTeX editor BlockTeXu

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

For finer-grained control, you could pass depth as a CSS custom property:

// potential future approach
<span style={{ '--depth': depth } as React.CSSProperties}>
Enter fullscreen mode Exit fullscreen mode

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;
  });
}
Enter fullscreen mode Exit fullscreen mode

Three things worth noting:

1. Preserve reference identity

return block;  // same reference = React skips re-rendering this subtree
Enter fullscreen mode Exit fullscreen mode

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 };
});
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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[]>([]);
Enter fullscreen mode Exit fullscreen mode

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)] }));
};
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)