DEV Community

HK Lee
HK Lee

Posted on • Originally published at pockit.tools

Why Your React App Re-renders Too Much: A Deep Dive into Performance Optimization

Why Your React App Re-renders Too Much: A Deep Dive into Performance Optimization

You've built a beautiful React application. The code is clean, the components are well-structured, and everything works. But something's wrong. Typing in a form field feels laggy. Scrolling through a list stutters. Opening a modal takes a noticeable moment. Your app feels... slow.

You open React DevTools Profiler, and your heart sinks. Components are re-rendering 47 times when you type a single character. A simple button click cascades into 200+ component updates. The entire app tree lights up like a Christmas tree on every state change.

You have a re-render problem. And you're not alone.

This is the most common performance issue in React applications, and it's also the most misunderstood. Developers reach for React.memo, useMemo, and useCallback like magic incantations, sprinkling them everywhere hoping something sticks. Spoiler: that approach usually makes things worse.

In this deep dive, we'll dissect exactly why React components re-render, identify the patterns that cause the most damage, and walk through real-world optimizations that reduced render counts by 80% in production applications. No cargo-cult programming—just understanding the system and applying targeted fixes.

The React Re-render Mental Model

Before optimizing, you need to understand what triggers a re-render. React's re-render behavior follows simple rules:

Rule 1: State Changes Trigger Re-renders

When a component's state changes via useState or useReducer, that component re-renders:

function Counter() {
  const [count, setCount] = useState(0);

  // Every click triggers a re-render of Counter
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Enter fullscreen mode Exit fullscreen mode

This is expected and necessary. No optimization needed here.

Rule 2: Parent Re-renders Cascade to Children

When a component re-renders, all of its children re-render too, regardless of whether their props changed:

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      {/* ExpensiveChild re-renders on EVERY count change */}
      {/* even though it receives no props related to count */}
      <ExpensiveChild />
    </div>
  );
}

function ExpensiveChild() {
  // This runs on every parent re-render
  console.log('ExpensiveChild rendered');
  return <div>I'm expensive to render</div>;
}
Enter fullscreen mode Exit fullscreen mode

This is the source of 90% of performance problems. The parent has state, so it re-renders. The child doesn't care about that state, but it re-renders anyway because React doesn't know that.

Rule 3: Context Changes Re-render All Consumers

Every component that calls useContext(SomeContext) will re-render when that context's value changes:

const ThemeContext = createContext({ theme: 'light' });

function App() {
  const [theme, setTheme] = useState('light');
  const [user, setUser] = useState(null);

  // Problem: changing user causes theme consumers to re-render
  // because the entire value object is recreated
  return (
    <ThemeContext.Provider value={{ theme, user, setUser }}>
      <ThemedButton /> {/* Re-renders when user changes! */}
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is the second biggest source of performance issues—context values that change too frequently or contain too much.

Identifying the Problem: React DevTools Profiler

Before you optimize anything, you need data. Open React DevTools and navigate to the Profiler tab.

Step 1: Record a Problematic Interaction

Click "Start profiling" and perform the action that feels slow. Type in an input, scroll a list, or toggle a modal. Then stop profiling.

Step 2: Analyze the Flamegraph

The flamegraph shows you:

  • Which components rendered (colored bars)
  • How long each render took (bar width)
  • Why they rendered (hover for details)

Look for:

  1. Components rendering when they shouldn't (gray bars that should be yellow)
  2. The same component rendering multiple times (repeated bars in timeline)
  3. Expensive components rendering frequently (wide bars appearing often)

Step 3: Enable "Highlight updates when components render"

In React DevTools settings, enable this option. Now interact with your app. Components that re-render will flash. If your entire app flashes when you type one character, you've found your problem.

The Biggest Re-render Mistakes (And How to Fix Them)

Mistake 1: Creating Objects/Arrays in Render

This is the most common mistake. Creating new objects or arrays during render causes child components to receive "new" props every time:

// ❌ BAD: Creates new array on every render
function TodoList({ todos }) {
  return (
    <List 
      items={todos.filter(t => !t.completed)} // New array every time
      config={{ showDates: true }} // New object every time
    />
  );
}

// ✅ GOOD: Stable references
function TodoList({ todos }) {
  const activeTodos = useMemo(
    () => todos.filter(t => !t.completed),
    [todos]
  );

  const config = useMemo(
    () => ({ showDates: true }),
    [] // Empty deps = never changes
  );

  return <List items={activeTodos} config={config} />;
}
Enter fullscreen mode Exit fullscreen mode

Even better—if the config never changes, move it outside the component:

// Best: Completely outside render cycle
const LIST_CONFIG = { showDates: true };

function TodoList({ todos }) {
  const activeTodos = useMemo(
    () => todos.filter(t => !t.completed),
    [todos]
  );

  return <List items={activeTodos} config={LIST_CONFIG} />;
}
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Inline Function Props

Passing inline functions as props creates new function references on every render:

// ❌ BAD: New function reference every render
function TodoItem({ todo, onToggle }) {
  return (
    <Checkbox 
      checked={todo.completed}
      onChange={() => onToggle(todo.id)} // New function every time
    />
  );
}

// ✅ GOOD: Stable callback
function TodoItem({ todo, onToggle }) {
  const handleToggle = useCallback(
    () => onToggle(todo.id),
    [todo.id, onToggle]
  );

  return (
    <Checkbox 
      checked={todo.completed}
      onChange={handleToggle}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Important: useCallback only helps if the child component is memoized (React.memo) or uses the callback in its own dependency arrays. Otherwise, you're adding overhead for no benefit.

Mistake 3: Lifting State Too High

State should live as close to where it's used as possible:

// ❌ BAD: Form state in App causes entire tree to re-render
function App() {
  const [formData, setFormData] = useState({ name: '', email: '' });

  return (
    <div>
      <Header /> {/* Re-renders on every keystroke */}
      <Sidebar /> {/* Re-renders on every keystroke */}
      <Form formData={formData} setFormData={setFormData} />
      <Footer /> {/* Re-renders on every keystroke */}
    </div>
  );
}

// ✅ GOOD: State colocated with usage
function App() {
  return (
    <div>
      <Header />
      <Sidebar />
      <Form /> {/* State lives here */}
      <Footer />
    </div>
  );
}

function Form() {
  const [formData, setFormData] = useState({ name: '', email: '' });
  // Only Form and its children re-render on keystroke
  return (/* ... */);
}
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Context Value Object Recreation

Context values are compared by reference. If you create a new object on every render, every consumer re-renders:

// ❌ BAD: New object every render = all consumers re-render
function AuthProvider({ children }) {
  const [user, setUser] = useState(null);

  return (
    <AuthContext.Provider value={{ user, setUser, isLoggedIn: !!user }}>
      {children}
    </AuthContext.Provider>
  );
}

// ✅ GOOD: Memoized value
function AuthProvider({ children }) {
  const [user, setUser] = useState(null);

  const value = useMemo(
    () => ({ user, setUser, isLoggedIn: !!user }),
    [user] // Only recreate when user changes
  );

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Mistake 5: Single Mega-Context

Putting everything in one context means every change re-renders every consumer:

// ❌ BAD: One context for everything
const AppContext = createContext({
  user: null,
  theme: 'light',
  notifications: [],
  sidebarOpen: false,
  // ... 20 more properties
});

// Every component using useContext(AppContext) re-renders
// when ANY of these values change

// ✅ GOOD: Split contexts by update frequency
const UserContext = createContext(null);      // Rarely changes
const ThemeContext = createContext('light');   // Almost never changes
const NotificationContext = createContext([]); // Changes frequently
const UIContext = createContext({});           // Changes on interaction
Enter fullscreen mode Exit fullscreen mode

Advanced Optimization Patterns

Pattern 1: Component Composition (Children as Props)

Instead of rendering children inside a stateful parent, pass them as props:

// ❌ BAD: Children re-render when parent state changes
function Modal({ isOpen }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  if (!isOpen) return null;

  return (
    <div style={{ top: position.y, left: position.x }}>
      <ExpensiveContent /> {/* Re-renders on drag */}
    </div>
  );
}

// ✅ GOOD: Children passed as props don't re-render
function Modal({ isOpen, children }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  if (!isOpen) return null;

  return (
    <div style={{ top: position.y, left: position.x }}>
      {children} {/* Reference is stable, no re-render */}
    </div>
  );
}

// Usage:
<Modal isOpen={isOpen}>
  <ExpensiveContent />
</Modal>
Enter fullscreen mode Exit fullscreen mode

This works because children is created in the parent of Modal, not inside Modal. When Modal's position state changes, the children prop reference stays the same.

Pattern 2: State Colocation with Extracting Components

When you have a component with mixed concerns—some state-heavy, some props-heavy—extract the stateful part:

// ❌ BAD: Mouse position causes entire list to re-render
function ItemList({ items }) {
  const [mousePos, setMousePos] = useState({ x: 0, y: 0 });

  return (
    <div onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}>
      <Cursor position={mousePos} />
      {items.map(item => (
        <ExpensiveItem key={item.id} item={item} /> {/* Re-renders on mouse move! */}
      ))}
    </div>
  );
}

// ✅ GOOD: Extract stateful part
function ItemList({ items }) {
  return (
    <div>
      <CursorTracker /> {/* Contains its own state */}
      {items.map(item => (
        <ExpensiveItem key={item.id} item={item} />
      ))}
    </div>
  );
}

function CursorTracker() {
  const [mousePos, setMousePos] = useState({ x: 0, y: 0 });

  return (
    <div onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}>
      <Cursor position={mousePos} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Selective Context Consumers

When you only need part of a context value, create a custom hook that subscribes selectively:

// Problem: useContext re-renders when ANY part of context changes
function UserAvatar() {
  const { user } = useContext(AppContext);
  // Re-renders when notifications change, theme changes, etc.
  return <img src={user.avatar} />;
}

// Solution: Use a state management library with selectors
// (Zustand, Jotai, or Redux with selectors)
import { create } from 'zustand';

const useStore = create((set) => ({
  user: null,
  theme: 'light',
  notifications: [],
  setUser: (user) => set({ user }),
}));

function UserAvatar() {
  // Only re-renders when user changes
  const user = useStore((state) => state.user);
  return <img src={user?.avatar} />;
}
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Virtualization for Long Lists

If you're rendering a list with 100+ items, virtualize it:

// ❌ BAD: Renders all 10,000 items
function MessageList({ messages }) {
  return (
    <div className="messages">
      {messages.map(msg => (
        <Message key={msg.id} message={msg} />
      ))}
    </div>
  );
}

// ✅ GOOD: Only renders visible items
import { Virtuoso } from 'react-virtuoso';

function MessageList({ messages }) {
  return (
    <Virtuoso
      data={messages}
      itemContent={(index, msg) => <Message message={msg} />}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Popular virtualization libraries:

  • react-virtuoso: Excellent for chat-like interfaces
  • @tanstack/react-virtual: Headless, flexible
  • react-window: Lightweight, battle-tested

When NOT to Optimize

Performance optimization has costs:

  1. Code complexity increases
  2. Debugging becomes harder
  3. Bugs can be introduced
  4. Premature optimization wastes time

Don't optimize if:

  • The component renders quickly (< 16ms)
  • The component rarely re-renders
  • Users haven't complained about performance
  • You don't have profiler data showing it's a problem

React is fast by default. The virtual DOM diffing algorithm is highly optimized. Most re-renders are cheap. Only optimize when you have evidence of a problem.

The 80% Rule: Real-World Results

In our production application, we applied these principles systematically:

Before:

  • Average of 847 component renders per user interaction
  • Input latency of 120ms
  • Frame drops during scrolling

Changes Made:

  1. Moved form state into forms (-40% renders)
  2. Split one mega-context into 5 focused contexts (-25% renders)
  3. Memoized expensive list item calculations (-10% renders)
  4. Virtualized the main message list (-15% renders, eliminated scroll jank)

After:

  • Average of 156 component renders per user interaction (81% reduction)
  • Input latency of 12ms
  • Smooth 60fps scrolling

The fixes took 2 days to implement. The profiling took 1 day. Understanding the problem was the hard part.

Debugging Checklist

When you encounter a performance issue, follow this checklist:

  1. Profile first - Use React DevTools Profiler to identify the actual problem
  2. Check for new object/array props - These are the most common culprits
  3. Look at context usage - Is a context value changing too frequently?
  4. Verify state location - Is state lifted higher than necessary?
  5. Check list rendering - Are you rendering hundreds of items without virtualization?
  6. Measure after changes - Did your optimization actually help?

Conclusion

React re-renders are not the enemy—unnecessary re-renders are. The framework is designed to be fast by default, but it can't read your mind about which updates are meaningful.

The best optimizations come from understanding your component tree:

  • Colocate state with the components that use it
  • Split contexts by update frequency
  • Use composition patterns to isolate updates
  • Memoize strategically, not everywhere
  • Virtualize long lists

Most importantly: measure before and after. Don't trust your instincts—trust the profiler.

Your users will never see how elegant your code is. They'll only feel how fast your app responds. Now you have the tools to give them that experience.


🛠️ Developer Toolkit: This post first appeared on the Pockit Blog.

Need a Regex Tester, JWT Decoder, or Image Converter? Use them on Pockit.tools or install the Extension to avoid switching tabs. No signup required.

Top comments (0)