← ./articles

Split React useMemo into Expensive Layout and Cheap Highlight Stages

useMemo helps only when its dependency list matches the work being cached.

A common performance bug is mixing expensive data transformation with transient UI state in one memo. The calculation is cached, but it still re-runs every time a user clicks a node, selects a row, or changes highlight state.

Split the memo into two stages: expensive layout first, cheap decoration second.

The symptom: every click recomputes the layout

The naive version looks reasonable:

const graphNodes = useMemo(() => {
  return computeGraphLayout(analyzeResult, selectedNodeId);
}, [analyzeResult, selectedNodeId]);

But if computeGraphLayout runs dagre, tree layout, aggregation, or sorting over a large dataset, selectedNodeId now invalidates the whole expensive calculation.

The UI janks on every selection click.

Stage 1: compute layout from stable data

First, compute geometry from the real data dependency:

const layoutNodes = useMemo(() => {
  return computeGraphLayout(analyzeResult);
}, [analyzeResult]);

This should include expensive work:

  • graph layout
  • hierarchy construction
  • grouping
  • aggregation
  • sorting large lists

It should not include selection, hover, or focus styling.

Stage 2: apply transient UI state cheaply

Then decorate the layout with selection state:

const renderedNodes = useMemo(() => {
  return layoutNodes.map((node) => ({
    ...node,
    selected: node.id === selectedNodeId,
    style: {
      ...node.style,
      stroke: node.id === selectedNodeId ? "#2563eb" : node.style?.stroke,
    },
  }));
}, [layoutNodes, selectedNodeId]);

Now a selection click only maps over existing nodes. The expensive layout stays cached until analyzeResult changes.

Keep the stages honest

Stage 1 should answer:

What are the nodes and where do they go?

Stage 2 should answer:

How should the already-positioned nodes look right now?

If a dependency changes geometry, it belongs in Stage 1. If it changes only styling, focus, selection, or badges, it belongs in Stage 2.

When not to split

Do not add this pattern everywhere.

Avoid the split when:

  • the calculation is cheap
  • there is no measured or visible jank
  • the transient state genuinely changes layout
  • the extra code makes a simple component harder to read

Use it when the expensive step is noticeable and the transient UI state changes often.

Verification checklist

Check that:

  • selecting a node does not call the layout function
  • changing source data does call the layout function
  • hover and selection remain visually correct
  • React Profiler shows shorter selection commits
  • the dependency arrays match the stage responsibilities
  • tests cover both data changes and selection changes

References