Skip to content

react, performance, architecture10 min read

The Most Underrated React Optimization: Component Tree Organization

React does exactly what you design it to do. When an application feels sluggish, the instinct is to blame the framework. Most of the time, the framework is fine. The tree it was given isn't.

Before memo, design the tree.

The component tree you write determines which components re-render, how often, and at what cost. Where state lives, who renders what, how components are composed: these are structural decisions with direct performance consequences. React follows them faithfully, whether you made them deliberately or not.

Most of the time, they aren't made deliberately. They accumulate. A feature needs data, so state moves up. Another feature needs the same data, so it stays. A layout wrapper grows. A Provider gets placed at the root "just in case." Six months later, a useState that belongs two levels down lives in a parent that re-renders its entire subtree on every interaction. Nobody put it there on purpose. Nobody moved it when the symptoms appeared.

This is not React misbehaving. This is React doing exactly what it was told.

By the end of this article, you will:

  • understand why the tree's structure — not memoization — is the primary lever for controlling re-renders
  • recognize two patterns that eliminate unnecessary renders without a single wrapper
  • see what benchmark data shows about the actual cost of getting this wrong

No new APIs. The optimization is entirely architectural.

Re-rendering is not the enemy

Here's something most React tutorials get backwards: re-rendering is not the problem.

Re-rendering is how React works. When state changes, React calls the component's function, generates a new UI description, compares it to the previous one, and updates the DOM where something differs. The cycle is fast, lightweight, and exactly right. Re-rendering is not a failure mode — it's the mechanism that keeps your UI synchronized with your state. A React application that doesn't re-render is a broken one.

The problem is unnecessary re-rendering: components being called whose output will not change, because nothing they depend on changed, because the only reason they ran is that their parent ran first.

This happens because rendering is recursive. When a component re-renders, React calls its children. And their children. And their children. Not because those components have anything to do with the state that changed — because they're downstream of the component that does. React doesn't analyze the tree to determine who actually depends on what. It propagates down and trusts its diffing algorithm to detect whether anything needs to update.

There are exactly two things that cause a component to re-render: its own state changes, or its parent re-renders. That's the complete list.

Now, why does unnecessary re-rendering hurt? A re-render involves two distinct phases. The Render Phase calls component functions, builds the new virtual DOM, and runs the diffing algorithm to identify what changed — cheaper than what follows, but not free. The Commit Phase applies those identified changes to the real DOM. DOM operations are among the most costly things a browser executes: layout recalculation, paint, compositing. React's O(n) diffing algorithm exists precisely to minimize them.

The risk of unnecessary re-renders is not only the extra function calls. It's what those calls can trigger. A component that re-renders when it shouldn't may produce output that diverges from what React cached — new object references passed as props to children, recomputed derived values, slightly different class names. React's diffing finds those differences and commits them to the real DOM. Mutations that would not have happened if the component had never been called. The unnecessary re-render was the condition. The DOM mutation was the cost.

React.memo addresses this by adding a prop comparison before the function call: if props haven't changed, skip the render. It's a valid tool. But it compensates for the parent re-rendering at all — it doesn't change where the propagation starts. Before reaching for memo, ask: why does this parent re-render in this component's context? Does it need to?

React Compiler answers that question with a machine. It inserts the memoization for you, at every level and more precisely than you would by hand — good enough that the benchmarks in this article largely close once it's enabled. Where it's enabled, and where your components follow the Rules of React closely enough to compile: the ones that don't are skipped, silently.

It is still compensation. The compiler caches around the tree you wrote; it cannot decide that a useState belongs two levels down, or that a Provider is feeding fifty components that never read it. Those are the decisions this article is about, and no compiler makes them for you.

Where the propagation should stop

Every unnecessary re-render is a propagation that traveled further than the state required. The structural question is always the same: where should this re-render have stopped?

Two patterns answer this question from different angles. Neither uses memoization. The optimization comes from redesigning where the boundary sits — before the render, not by intercepting it after.

Moving the source

The most common case: state lives higher than the component that uses it, and unrelated components are downstream.

.jsx
// ❌ count lives in Parent — expensive siblings re-render on every click
function Parent() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <ExpensiveComponent />
      <AnotherExpensiveComponent />
    </div>
  );
}

Parent holds the state. Every click re-renders it — which re-renders ExpensiveComponent and AnotherExpensiveComponent. Both have nothing to do with count. They don't read it. They don't render anything that depends on it. They run because they're downstream of a component that changed. That's the structural failure.

Moving the state down stops the propagation at the right place.

.jsx
// ✅ count lives in Counter — expensive siblings are untouched
function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
 
function Parent() {
  return (
    <div>
      <Counter />
      <ExpensiveComponent />
      <AnotherExpensiveComponent />
    </div>
  );
}

Parent has no state. It never re-renders on its own. When the user clicks, Counter re-renders — and the propagation stops there, because ExpensiveComponent and AnotherExpensiveComponent are Counter's siblings, not its children. The tree shape is doing the work.

Benchmark comparing state kept in the parent versus moved down to Counter, over 100 clicks
After 100 clicks: state in Parent averages 3.53ms per render (352.90ms total). Moved down to Counter, 0.08ms — 4,367% faster — because Parent renders once, on mount, and never again.

The principle

If moving state down doesn't break anything, it should be down.

Blocking the path

Some state genuinely can't be moved down. The stateful component is structural — it needs to surround the expensive content as part of the layout. Moving the state out would break the component's purpose.

The classic case: an input that filters a list, where both live inside the same wrapper.

.jsx
// ❌ SearchLayout owns state — ExpensiveList re-renders on every keystroke
function SearchLayout() {
  const [query, setQuery] = useState('');
 
  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ExpensiveList />
    </div>
  );
}

Every keystroke re-renders SearchLayout. ExpensiveList is its child — it re-renders with it, on every single keystroke, with no change to its output.

Benchmark showing ExpensiveList re-rendering on every keystroke because it's a child of the stateful SearchLayout
After 100 keystrokes: 3.54ms average, 353.60ms total. ExpensiveList renders 100 times.

ExpensiveList renders 100 times. You can't colocate the state here. Most developers install React.memo on ExpensiveList and move on. But the structure can be inverted.

.jsx
// ✅ StatefulInput receives expensive content as children — its reference stays stable
function StatefulInput({ children }) {
  const [query, setQuery] = useState('');
  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {children}
    </div>
  );
}
 
function SearchLayout() {
  return (
    <StatefulInput>
      <ExpensiveList />
    </StatefulInput>
  );
}

StatefulInput owns the state — but ExpensiveList is created by SearchLayout and passed in as children. When the user types and StatefulInput re-renders, it renders {children}. That children is the React element object SearchLayout produced. SearchLayout has no state, hasn't re-rendered, and the element it passed carries the same reference it carried last time.

React sees the same element reference at the same position. It skips calling ExpensiveList's function.

Benchmark showing ExpensiveList rendering only once after lifting it into children, across 100 keystrokes
Same 100 keystrokes: 0.08ms average, 7.70ms total. StatefulInput renders 100 times. ExpensiveList renders once — a 4,492% difference. No memo.

The tree as a design decision

Two scenarios, two different causes, two different fixes. The underlying pattern is identical in each: a structural decision — where state lives, who creates a component — determines which components re-render when something changes. Getting that decision right eliminates the problem. Getting it wrong creates a performance problem that memo can patch but cannot solve.

State belongs at its closest common ancestor — not its most convenient one. Every level above that is unnecessary propagation. Every unnecessary level is a place where memo will eventually appear and nobody will remember why it's there.

Update frequency is a design dimension. State that changes on every keystroke and state that changes when a modal opens have entirely different propagation costs. Mixing them in the same component means the fast-changing state forces re-renders in everything downstream of the slow-changing one too. The tree should reflect change rates — not which features appear to belong together on screen.

Memoization is a signal, not a solution. Reaching for React.memo is an acknowledgment that a component re-renders more than it should, and restructuring isn't worth it right now. Sometimes that's the right call. But it's a patch — and patches accumulate. A codebase covered in memo wrappers is a codebase full of structural decisions that were never actually made.

React Compiler removes the wrappers, not the decisions — and it removes the symptom that used to warn you they were pending. A tree with state in the wrong place stops being slow. It is still the tree where a value is threaded through five components that never read it, where moving a feature means tracing which ancestor owns its state, and where a new engineer reads four files to answer a question the structure should have answered. The compiler makes that fast. It does not make it legible.

Starting from the tree

The shift this article asks for is about the order of questions.

Before opening the profiler, before reaching for memo, ask: where does the state that's causing this re-render need to live? If it's higher than its nearest consumer, move it down. If it can't be moved, can the expensive component be passed as children from a stable outer scope?

If you want to test that question before writing a single component, Treeact lets you sketch the tree, mark where state and children live, and watch the re-render cascade before it exists in code.

These questions are faster to answer than post-hoc debugging. They leave the codebase simpler than they found it. And they address the cause — not the symptom.

The hard part isn't learning the patterns. It's building the habit of treating the component tree as a first-class design decision: something you think about before writing a component, not something you patch after the profiler tells you something is slow.

Most trees aren't designed. They're accumulated.

Design the tree. memo is what happens when you don't.