Steve Kinney

Understanding Reconciliation in React 19

React’s reconciliation process is the magic behind how your components update efficiently—but it’s also where most performance problems hide. If you’ve ever wondered why your React app feels sluggish, or why changing one component causes unexpected re-renders elsewhere, you’re in the right place. Let’s demystify how React compares component trees, decides what needs updating, and schedules that work in React 19.

By the end of this, you’ll understand the reconciliation algorithm well enough to write components that work with React instead of fighting against it (and debug performance issues like the seasoned engineer you’re becoming).

What is Reconciliation?

Reconciliation is React’s process for figuring out what changed between renders and updating the DOM accordingly. Think of it like a very smart diff algorithm—React compares the new component tree (what you want to render) against the previous tree (what’s currently on screen) and calculates the minimal set of changes needed.

Here’s the key insight: React never directly manipulates the DOM during render. Instead, it builds a virtual representation of what the UI should look like, compares it to the previous version, and then applies only the necessary changes. This process happens in two phases:

  1. Render Phase: React calls your components and builds the new virtual tree
  2. Commit Phase: React applies the calculated changes to the actual DOM

The reconciliation algorithm is what makes React fast—instead of re-creating the entire DOM on every state change, it surgically updates only what needs to change.

The Reconciliation Algorithm

React’s reconciliation follows a set of predictable rules. Understanding these rules is crucial because they determine when your components re-render and how efficiently your app performs.

Rule 1: Different Element Types = Complete Replacement

When React encounters elements of different types in the same position, it tears down the entire subtree and builds a new one from scratch.

// ❌ This will destroy and recreate the entire subtree
function Application({ isLoggedIn }: { isLoggedIn: boolean }) {
  if (isLoggedIn) {
    return <div>Welcome back!</div>;
  }
  return <span>Please log in</span>; // Different element type!
}

// ✅ Better: Same element type, different content
function Application({ isLoggedIn }: { isLoggedIn: boolean }) {
  return <div>{isLoggedIn ? 'Welcome back!' : 'Please log in'}</div>;
}

In the first example, switching between div and span causes React to:

  1. Unmount the old div and call cleanup effects
  2. Create a new span from scratch
  3. Run all initialization effects

This is expensive and loses component state. The second approach keeps the same div and only updates its text content.

Rule 2: Elements with Keys are Compared by Key

Keys help React identify which items have changed, been added, or removed. Without keys, React uses position-based matching, which can cause performance issues and bugs.

// ❌ Without keys, React matches by position
function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li>{todo.text}</li> // No key!
      ))}
    </ul>
  );
}

// ✅ With keys, React matches by identity
function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

Here’s what happens when you add an item to the beginning of the list:

Without keys: React thinks you modified every existing item and added one at the end. Every <li> re-renders unnecessarily.

With keys: React recognizes the existing items by their keys and only creates the new item. Much more efficient!

Don’t use array indices as keys if your list can change order. When items shift positions, React will see different keys in different positions and think items have changed.

Rule 3: Same Component Type = Reconcile Props and State

When React encounters the same component type in the same position, it keeps the component instance alive and updates only the changed props.

function UserProfile({ userId, theme }: { userId: string; theme: 'light' | 'dark' }) {
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  return <div className={theme}>{user ? <h1>{user.name}</h1> : <div>Loading...</div>}</div>;
}

// When parent re-renders with same userId but different theme:
// - Component instance stays the same
// - State (user) is preserved
// - Only the className updates
// - useEffect doesn't re-run because userId didn't change

This is why React components can maintain state across re-renders—as long as they stay in the same position with the same type, React preserves the component instance.

React 19’s Reconciliation Improvements

React 19 introduces several enhancements that make reconciliation even more efficient and predictable.

Automatic Batching Everywhere

React 19 extends automatic batching to work consistently across all contexts—including setTimeout, promises, and native event handlers.

// ✅ In React 19, these are automatically batched
function handleClick() {
  setCount(count + 1);
  setName('Alice');
  setActive(true);
  // Only one re-render happens, not three!
}

// ✅ Even in async contexts
setTimeout(() => {
  setCount(count + 1);
  setName('Bob');
  // Still batched in React 19!
}, 1000);

This means fewer unnecessary re-renders and better performance out of the box.

Improved Concurrent Features

React 19’s concurrent renderer can interrupt low-priority work to handle high-priority updates (like user input) immediately.

// High-priority update (user typing)
function SearchInput() {
  const [query, setQuery] = useState('');

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)} // High priority
    />
  );
}

// Low-priority update (expensive search results)
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    // This work can be interrupted for high-priority updates
    searchExpensively(query).then(setResults);
  }, [query]);

  return (
    <div>
      {results.map((result) => (
        <ResultItem key={result.id} result={result} />
      ))}
    </div>
  );
}

React 19 ensures that typing in the search box stays responsive even while expensive search operations are running in the background.

Common Reconciliation Performance Pitfalls

Understanding reconciliation helps you avoid these common performance traps:

Pitfall 1: Creating Objects in Render

// ❌ Creates a new object every render
function UserCard({ user }: { user: User }) {
  return (
    <UserAvatar
      user={user}
      style={{ borderRadius: '50%', width: 40 }} // New object!
    />
  );
}

// ✅ Stable reference
const AVATAR_STYLE = { borderRadius: '50%', width: 40 };

function UserCard({ user }: { user: User }) {
  return <UserAvatar user={user} style={AVATAR_STYLE} />;
}

The first version creates a new style object on every render, causing UserAvatar to think its props have changed and re-render unnecessarily.

Pitfall 2: Functions as Props

// ❌ New function every render
function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <div>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={() => toggleTodo(todo.id)} // New function!
        />
      ))}
    </div>
  );
}

// ✅ Stable callback with useCallback
function TodoList({ todos }: { todos: Todo[] }) {
  const handleToggle = useCallback((todoId: string) => {
    toggleTodo(todoId);
  }, []);

  return (
    <div>
      {todos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} onToggle={() => handleToggle(todo.id)} />
      ))}
    </div>
  );
}
In React 19, you might not need

useCallback as much thanks to the React Compiler, which can automatically memoize functions when beneficial.

Pitfall 3: Conditional Rendering Position Changes

// ❌ Component position changes based on condition
function Dashboard({ showSidebar }: { showSidebar: boolean }) {
  return (
    <div>
      {showSidebar && <Sidebar />}
      <MainContent />
    </div>
  );
}

// ✅ Consistent component positions
function Dashboard({ showSidebar }: { showSidebar: boolean }) {
  return (
    <div>
      <Sidebar visible={showSidebar} />
      <MainContent />
    </div>
  );
}

In the first example, MainContent’s position in the tree changes when showSidebar toggles, potentially causing unnecessary re-renders. The second approach keeps positions stable.

Optimizing for Reconciliation

Here are some practical strategies for writing reconciliation-friendly components:

Use React.memo() Strategically

// ✅ Memo prevents re-renders when props haven't changed
const ExpensiveComponent = React.memo(function ExpensiveComponent({
  data,
  onUpdate,
}: {
  data: ComplexData;
  onUpdate: (id: string) => void;
}) {
  // Expensive rendering logic here
  return <div>{/* Complex UI */}</div>;
});

React.memo() does a shallow comparison of props and skips re-rendering if nothing changed. It’s particularly useful for components that receive stable props but have expensive rendering logic.

Lift State Down

// ❌ High-level state causes unnecessary re-renders
function App() {
  const [count, setCount] = useState(0);
  const [user, setUser] = useState(null);

  return (
    <div>
      <Header user={user} /> {/* Re-renders when count changes */}
      <Counter count={count} onIncrement={() => setCount((c) => c + 1)} />
      <Footer />
    </div>
  );
}

// ✅ State lives closer to where it's needed
function App() {
  const [user, setUser] = useState(null);

  return (
    <div>
      <Header user={user} /> {/* Stable now */}
      <CounterSection /> {/* Count state lives here */}
      <Footer />
    </div>
  );
}

function CounterSection() {
  const [count, setCount] = useState(0);
  return <Counter count={count} onIncrement={() => setCount((c) => c + 1)} />;
}

Moving state closer to where it’s used reduces the number of components that need to re-render when that state changes.

Separate Frequently-Changing State

// ❌ Mouse position causes everything to re-render
function App() {
  const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
  const [user, setUser] = useState(null);

  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
      setMousePos({ x: e.clientX, y: e.clientY });
    };

    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);

  return (
    <div>
      <UserProfile user={user} /> {/* Re-renders constantly! */}
      <MouseTracker position={mousePos} />
    </div>
  );
}

// ✅ Isolate frequently-changing state
function App() {
  const [user, setUser] = useState(null);

  return (
    <div>
      <UserProfile user={user} /> {/* Stable */}
      <MouseTrackerWrapper /> {/* Mouse state isolated here */}
    </div>
  );
}

Isolating rapidly-changing state prevents it from triggering unnecessary re-renders in unrelated components.

Debugging Reconciliation Issues

React DevTools provides excellent insights into reconciliation performance:

Profiler Tab

  1. Open React DevTools
  2. Go to the “Profiler” tab
  3. Click “Record” and interact with your app
  4. Stop recording to see which components re-rendered and why

Look for:

  • Unexpected re-renders: Components that shouldn’t have updated
  • Expensive renders: Components taking a long time to render
  • Cascading updates: One change triggering many others

Why Did You Render

The @welldone-software/why-did-you-render library can automatically detect unnecessary re-renders:

// In development only
if (process.env.NODE_ENV === 'development') {
  const whyDidYouRender = require('@welldone-software/why-did-you-render');
  whyDidYouRender(React);
}

// Mark components to track
MyComponent.whyDidYouRender = true;

This will log to the console whenever MyComponent re-renders with the same props, helping you identify reconciliation inefficiencies.

Real-World Performance Patterns

Here are some patterns I’ve found effective in production React applications:

Virtual Scrolling for Large Lists

// ✅ Only render visible items
import { FixedSizeList as List } from 'react-window';

function VirtualizedList({ items }: { items: Item[] }) {
  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
    <div style={style}>
      <ItemComponent item={items[index]} />
    </div>
  );

  return (
    <List height={600} itemCount={items.length} itemSize={50}>
      {Row}
    </List>
  );
}

Virtual scrolling only renders the items currently visible, keeping reconciliation work constant regardless of list size.

Compound Components for Complex UIs

// ✅ Stable structure, flexible content
function DataTable({ children }: { children: React.ReactNode }) {
  return <table>{children}</table>;
}

DataTable.Header = function TableHeader({ children }: { children: React.ReactNode }) {
  return <thead>{children}</thead>;
};

DataTable.Body = function TableBody({ children }: { children: React.ReactNode }) {
  return <tbody>{children}</tbody>;
};

// Usage maintains consistent tree structure
function UserTable({ users }: { users: User[] }) {
  return (
    <DataTable>
      <DataTable.Header>
        <tr>
          <th>Name</th>
          <th>Email</th>
        </tr>
      </DataTable.Header>
      <DataTable.Body>
        {users.map((user) => (
          <tr key={user.id}>
            <td>{user.name}</td>
            <td>{user.email}</td>
          </tr>
        ))}
      </DataTable.Body>
    </DataTable>
  );
}

Compound components provide consistent tree structure while keeping the API flexible and reconciliation-friendly.

Next Steps

Understanding reconciliation gives you a mental model for writing efficient React components. Here’s what to focus on next:

Last modified on .