Performance: When React Gets Slow

SS Saurav Sitaula

Fix slow React apps with this performance optimization guide. Learn why components re-render, how to use React.memo to prevent unnecessary renders, when to apply useMemo and useCallback, and how to profile with React DevTools. Includes virtualization, code splitting, and real-world optimization patterns.

The Moment I Knew Something Was Wrong

After learning Context and building a decently complex dashboard, I noticed something: typing in a search box felt sluggish.

Every keystroke had a noticeable delay. The cursor would lag behind my fingers.

I opened React DevTools, turned on “Highlight updates when components render,” and typed a single character.

The entire page flashed.

Not just the search box. Not just the results. The navigation. The sidebar. The footer. The user avatar. Everything was re-rendering on every single keystroke.

Oh no.

Why React Re-renders

To fix this, I first had to understand the problem. Here’s how React decides to re-render:

  1. State changes → The component re-renders
  2. Props change → The component re-renders
  3. Parent re-renders → All children re-render (even if their props didn’t change!)

That third one is the killer.

function Parent() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <Child />  {/* Re-renders every time Parent re-renders! */}
    </div>
  );
}

function Child() {
  console.log('Child rendered');  // Logs on EVERY parent re-render
  return <div>I'm just chilling here</div>;
}

Child doesn’t use count. It doesn’t receive any props. But it still re-renders because its parent re-rendered.

For small apps? No big deal. For complex apps with heavy components? Death by a thousand re-renders.

Tool #1: React.memo

React.memo is a higher-order component that memoizes your component. It only re-renders if the props actually changed.

const Child = React.memo(function Child() {
  console.log('Child rendered');  // Only logs when props change
  return <div>I'm just chilling here</div>;
});

function Parent() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <Child />  {/* No longer re-renders on parent state change! */}
    </div>
  );
}

Magic? No. React compares the previous props with the next props. If they’re the same (shallow comparison), skip the re-render.

The Problem With Object and Function Props

Here’s where it gets tricky:

const Child = React.memo(function Child({ style, onClick }) {
  console.log('Child rendered');
  return <button style={style} onClick={onClick}>Click me</button>;
});

function Parent() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <Child 
        style={{ color: 'blue' }}  // 🚨 New object every render!
        onClick={() => console.log('clicked')}  // 🚨 New function every render!
      />
    </div>
  );
}

React.memo compares props with ===. And here’s the thing:

{ color: 'blue' } === { color: 'blue' }  // false!
(() => {}) === (() => {})  // false!

Even though the values are the same, they’re new objects/functions in memory. So React.memo thinks the props changed.

This is where useMemo and useCallback come in.

Tool #2: useMemo

useMemo memoizes a computed value. It only recalculates when its dependencies change.

function Parent() {
  const [count, setCount] = useState(0);
  
  // ✅ Same object reference unless something changes
  const style = useMemo(() => ({ color: 'blue' }), []);
  
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <Child style={style} />  {/* Won't re-render! */}
    </div>
  );
}

The [] dependency array means “only compute this once.” If you had dynamic values:

const style = useMemo(() => ({ 
  color: isActive ? 'blue' : 'gray' 
}), [isActive]);  // Recalculates when isActive changes

Tool #3: useCallback

useCallback memoizes a function. It’s basically useMemo for functions.

function Parent() {
  const [count, setCount] = useState(0);
  
  // ✅ Same function reference unless dependencies change
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);
  
  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <Child onClick={handleClick} />  {/* Won't re-render! */}
    </div>
  );
}

Putting It All Together

Here’s my dashboard search component, before and after optimization:

Before (Everything Re-renders)

function Dashboard() {
  const [searchQuery, setSearchQuery] = useState('');
  const [data, setData] = useState([]);
  
  return (
    <div>
      <SearchBox 
        value={searchQuery} 
        onChange={(e) => setSearchQuery(e.target.value)} 
      />
      <Sidebar />  {/* Re-renders on every keystroke! */}
      <UserStats />  {/* Re-renders on every keystroke! */}
      <DataGrid data={data} />  {/* Re-renders on every keystroke! */}
    </div>
  );
}
// Memoize expensive components
const Sidebar = React.memo(function Sidebar() {
  console.log('Sidebar render');
  return <aside>...</aside>;
});

const UserStats = React.memo(function UserStats() {
  console.log('UserStats render');
  return <div>...</div>;
});

const DataGrid = React.memo(function DataGrid({ data }) {
  console.log('DataGrid render');
  return <table>...</table>;
});

function Dashboard() {
  const [searchQuery, setSearchQuery] = useState('');
  const [data, setData] = useState([]);
  
  // Memoize the change handler
  const handleSearchChange = useCallback((e) => {
    setSearchQuery(e.target.value);
  }, []);
  
  return (
    <div>
      <SearchBox value={searchQuery} onChange={handleSearchChange} />
      <Sidebar />  {/* Doesn't re-render! */}
      <UserStats />  {/* Doesn't re-render! */}
      <DataGrid data={data} />  {/* Only re-renders when data changes! */}
    </div>
  );
}

Typing in the search box now only re-renders SearchBox. Everything else stays frozen.

The useMemo for Expensive Calculations

useMemo isn’t just for preventing re-renders. It’s also for avoiding expensive recalculations:

function DataAnalytics({ data }) {
  // 🚨 Recalculates on EVERY render, even if data didn't change
  const stats = calculateComplexStats(data);  // Takes 500ms
  
  return <div>Average: {stats.average}</div>;
}

function DataAnalytics({ data }) {
  // ✅ Only recalculates when data changes
  const stats = useMemo(() => calculateComplexStats(data), [data]);
  
  return <div>Average: {stats.average}</div>;
}

When NOT to Optimize

Here’s a trap I fell into: premature optimization.

I started wrapping EVERYTHING in React.memo, useMemo, and useCallback. Every component, every value, every function.

Don’t do this.

Memoization has a cost:

  • Memory to store the cached values
  • Comparison logic on every render
  • More complex code to read and maintain

For simple components that render fast, the overhead of memoization might be MORE than just re-rendering.

Rules of thumb:

  1. Don’t optimize until you have a problem. Use React DevTools Profiler to find actual bottlenecks.

  2. Start with React.memo on expensive components. If a component takes > 16ms to render, it’s worth memoizing.

  3. Only use useCallback/useMemo for:

    • Props passed to memoized components
    • Expensive calculations
    • Values used in dependency arrays of other hooks
  4. Primitives don’t need memoization. Strings, numbers, booleans are compared by value, not reference.

// ❌ Useless - primitives are already stable
const name = useMemo(() => 'John', []);

// ❌ Useless - simple objects that aren't passed to memoized children
const config = useMemo(() => ({ debug: true }), []);

// ✅ Useful - passed to React.memo component
const style = useMemo(() => ({ color: 'blue' }), []);
<MemoizedChild style={style} />

// ✅ Useful - expensive calculation
const sorted = useMemo(() => hugeArray.sort(complexSortFn), [hugeArray]);

The Profiler Is Your Friend

React DevTools has a Profiler tab. Use it.

  1. Start recording
  2. Interact with your app
  3. Stop recording
  4. Look at the flame graph

You’ll see:

  • Which components rendered
  • How long each render took
  • Why they rendered (props changed? parent re-rendered?)

This tells you where to focus optimization efforts.

Other Performance Wins

Beyond memoization, here are other things that helped:

Virtualization for Long Lists

// 🚨 Rendering 10,000 items
{items.map(item => <Item key={item.id} {...item} />)}

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

<FixedSizeList
  height={400}
  itemCount={items.length}
  itemSize={50}
>
  {({ index, style }) => (
    <Item style={style} {...items[index]} />
  )}
</FixedSizeList>

Debouncing Input

function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);
  
  useEffect(() => {
    if (debouncedQuery) {
      fetchResults(debouncedQuery);  // Only fetches after typing stops
    }
  }, [debouncedQuery]);
  
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

Lazy Loading Components

const HeavyChart = React.lazy(() => import('./HeavyChart'));

function Dashboard() {
  return (
    <Suspense fallback={<Loading />}>
      <HeavyChart />  {/* Only loads when needed */}
    </Suspense>
  );
}

Moving State Down

Instead of lifting state up (which causes parent re-renders), sometimes you can push it down:

// 🚨 Parent re-renders on every input change
function Form() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  
  return (
    <div>
      <NameInput value={name} onChange={setName} />
      <EmailInput value={email} onChange={setEmail} />
      <ExpensiveComponent />  {/* Re-renders on every keystroke! */}
    </div>
  );
}

// ✅ Isolate the changing state
function Form() {
  return (
    <div>
      <NameInput />  {/* Manages its own state */}
      <EmailInput />  {/* Manages its own state */}
      <ExpensiveComponent />  {/* Never re-renders! */}
    </div>
  );
}

function NameInput() {
  const [name, setName] = useState('');
  return <input value={name} onChange={e => setName(e.target.value)} />;
}

What I Wish I’d Known Earlier

  1. Profile first, optimize second. Don’t guess where the problem is. Measure it.

  2. React.memo is the big win. useMemo and useCallback only matter when passing props to memoized components.

  3. Not all re-renders are bad. Re-rendering is what React does. It’s usually fast. Only optimize when it’s actually slow.

  4. Composition beats memoization. Sometimes restructuring your components eliminates the need for optimization entirely.

  5. The dependency array is critical. Get it wrong and you’ll either have stale values or infinite loops. Trust the linter.

The Journey Continues

Performance optimization was humbling. I thought I understood React, then I watched my app crawl because I didn’t understand how re-rendering worked.

But with React.memo, useMemo, useCallback, and the Profiler, I had the tools to build apps that stayed fast as they grew.

These posts have covered my journey from HTML files to understanding the core of modern React. Components, state, lifecycles, hooks, custom hooks, context, and performance. It’s been a wild ride.

What’s next? Server components, suspense for data fetching, concurrent rendering… React keeps evolving. But the fundamentals? Those stay the same.


P.S. — My biggest performance win wasn’t any of these tools. It was realizing I was fetching the same data 5 times because I didn’t understand when components mounted. Understanding the render cycle > memorizing optimization tricks. Always.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism