February 3, 2026
React offers a declarative API that simplifies building interactive interfaces. By abstracting DOM manipulation, developers can focus on writing state transitions. However, as applications grow, this abstraction can lead to performance bottlenecks.
Understanding how React executes components under the hood is critical to keeping your application fast. In this article, we will explore the internals of the Fiber reconciliation engine, analyze the overhead of memoization hooks, build a custom virtualized list from scratch, and optimize React Context API to avoid useless re-renders.
To optimize a React application, you must first understand the distinction between the Render phase and the Commit phase.
React Rendering Pipeline
+-------------------------------------------------------------------------+
| RENDER PHASE |
| (Asynchronous, Concurrent, interruptible) |
+-------------------------------------------------------------------------+
| 1. Trigger State Update -> 2. VDOM Tree Rebuilding -> 3. Diffing (Fiber) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| COMMIT PHASE |
| (Synchronous, DOM mutation, fast) |
+-------------------------------------------------------------------------+
| 4. Apply changes to DOM -> 5. Run LayoutEffects -> 6. Run Effects |
+-------------------------------------------------------------------------+
The Render phase is where React traverses the component tree, evaluates props and state, and constructs a Virtual DOM. In React 16+, this process is managed by the Fiber Engine.
A "Fiber" is a plain JavaScript object containing metadata about a component, its state, its child-sibling relationships, and a queue of updates. Unlike the old stack reconciler, Fiber works asynchronously. V8 can pause the tree traversal to handle urgent user inputs (like typing in a text field) and resume it later.
During the render phase, React executes the component function, gets the returned JSX elements, and compares them with the previous Fiber node tree. This comparison is called Diffing. The output of the Render phase is a list of side effects (such as insertions, deletions, or updates) to be applied to the real DOM.
The Commit phase is synchronous. React takes the list of side effects generated in the Render phase and writes them directly to the real DOM using DOM APIs (like appendChild or removeChild).
Once the DOM is updated, React runs the layout effects (useLayoutEffect), updates refs, and finally triggers asynchronous effects (useEffect) after the browser paints the updated layout on the screen.
The Golden Rule: Re-rendering is not the same as writing to the DOM. A component can re-render (execute its function body) during the Render phase, but if the diffing engine determines that the returned JSX structure is identical, React will make zero writes to the real DOM in the Commit phase. However, running the Render phase on large trees still consumes substantial CPU and JS main thread budget.
useMemo and useCallback PitfallsMany developers believe that wrapping every array in useMemo and every handler function in useCallback is a free performance boost. In reality, premature or incorrect memoization can make your application slower.
Every hook call has a runtime cost:
Object.is) on each dependency.Consider this component:
// Bad practice: Memoization overhead exceeds calculation cost
const SimpleButton = ({ onClick, title }) => {
const formattedTitle = useMemo(() => {
return title.trim().toUpperCase();
}, [title]);
const handleClick = useCallback(() => {
onClick(formattedTitle);
}, [onClick, formattedTitle]);
return <button onClick={handleClick}>{formattedTitle}</button>;
};
Why is this bad?
The operation title.trim().toUpperCase() takes less than a microsecond. The cost of setting up the useMemo hook, allocating its dependency array, and performing comparison checks on every render is higher than executing the string operation itself.
Furthermore, SimpleButton re-renders every time its parent does, because we did not wrap the component itself in React.memo(). If the child component is not memoized, stabilizing its prop callbacks via useCallback is entirely useless.
Use useMemo and useCallback under only two conditions:
React.memo, or utilizing them inside dependency arrays of other hooks (useEffect).When rendering a list of 5,000 elements, creating DOM nodes for all of them will immediately cause interface lag (high LCP and INP metrics). List virtualization solves this by rendering only the subset of items currently visible within the scrolling container.
Let us build a simple, responsive vertical list virtualizer from scratch to understand the architecture:
import React, { useState, useRef, useEffect } from 'react';
export const VirtualList = ({ items, itemHeight, containerHeight }) => {
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
const handleScroll = (e) => {
setScrollTop(e.currentTarget.scrollTop);
};
const totalHeight = items.length * itemHeight;
// Calculate indices of visible items
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - 2); // 2 items buffer above
const endIndex = Math.min(
items.length - 1,
Math.floor((scrollTop + containerHeight) / itemHeight) + 2 // 2 items buffer below
);
const visibleItems = [];
for (let i = startIndex; i <= endIndex; i++) {
visibleItems.push({
item: items[i],
index: i,
style: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${itemHeight}px`,
transform: `translateY(${i * itemHeight}px)`,
}
});
}
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{
overflowY: 'auto',
height: `${containerHeight}px`,
position: 'relative',
border: '1px solid rgba(255,255,255,0.1)',
}}
>
<div style={{ height: `${totalHeight}px`, width: '100%', position: 'relative' }}>
{visibleItems.map(({ item, style, index }) => (
<div key={index} style={style}>
<div className="list-item-card">
<span>#{index + 1}</span> {item.name}
</div>
</div>
))}
</div>
</div>
);
};
How it works:
overflow-y: auto and a fixed containerHeight.items.length * itemHeight). This ensures the native scrollbar behaves correctly.scrollTop value of the container.startIndex and endIndex based on the scroll position. Adding a buffer of 2 elements above and below avoids flickering when scrolling quickly.translateY(index * itemHeight). This keeps the DOM small and fast.React Context is a convenient way to share state, but it has a major performance flaw: every time the context value changes, all consumers of the context are re-rendered, regardless of which part of the state they actually read.
// Bad practice: Causes every consumer to re-render on any change
const UserContext = React.createContext();
export const UserProvider = ({ children }) => {
const [state, setState] = useState({ username: '', theme: 'dark' });
// The context value object changes reference on every render
return (
<UserContext.Provider value={{ state, setState }}>
{children}
</UserContext.Provider>
);
};
If a component only uses the setState function to trigger changes, it will still re-render whenever the state updates, because they share the same context channel.
By creating two separate contexts (one for the state values and one for the update functions), you can decouple readers from writers:
import React, { createContext, useContext, useState, useMemo } from 'react';
const UserStateContext = createContext(null);
const UserDispatchContext = createContext(null);
export const UserProvider = ({ children }) => {
const [state, setState] = useState({ username: '', theme: 'dark' });
// Stable dispatch handler reference
const dispatch = useMemo(() => ({
setUsername: (name) => setState(prev => ({ ...prev, username: name })),
setTheme: (theme) => setState(prev => ({ ...prev, theme }))
}), []);
return (
<UserStateContext.Provider value={state}>
<UserDispatchContext.Provider value={dispatch}>
{children}
</UserDispatchContext.Provider>
</UserStateContext.Provider>
);
};
// Custom hooks for safe consumption
export const useUserState = () => {
const context = useContext(UserStateContext);
if (!context) throw new Error('useUserState must be used within UserProvider');
return context;
};
export const useUserDispatch = () => {
const context = useContext(UserDispatchContext);
if (!context) throw new Error('useUserDispatch must be used within UserProvider');
return context;
};
Now, a component that only updates the user state (e.g., a setting toggle button) will call useUserDispatch. Since the dispatch reference remains stable, the toggle button will never re-render when the username or theme changes.
Never guess where performance issues are originating. Use the browser React Developer Tools extension:
Props changed: [onClick]).Using these techniques, you can identify performance bottlenecks and build fluid, high-performing user interfaces.