Sitemap

Performance Optimization: Making React Apps Fast

10 min readApr 25, 2026

--

Press enter or click to view image in full size

When Your App Feels Slow

You build a React app. It works. Then you add features. More components. More data. More interactivity. Suddenly everything feels sluggish. Typing lags. Scrolling stutters. Clicks delay. Users complain.

You check the code. Looks fine. But every keystroke triggers re-renders. Parent component re-renders all children. Expensive calculations run repeatedly. Large lists render thousands of items. Memory grows. Performance tanks.

You try random fixes. Add React.memo everywhere. Cache everything. Nothing helps. You don’t understand why components re-render. You don’t know which optimizations matter. You make code complex for no benefit.

Performance optimization requires understanding. Why do components re-render? What makes renders expensive? When does optimization help? When does it hurt? Learn the principles. Apply the right techniques. Make your app fast.

QUICK SUMMARY

What You’ll Learn: React re-renders when props or state change. Prevent unnecessary re-renders with React.memo for components. Use useMemo to cache expensive calculations. Use useCallback to prevent function recreation. Lazy load components with React.lazy and Suspense for code splitting. Virtualize long lists with react-window rendering only visible items. Debounce expensive operations like API calls. Use Chrome DevTools Performance tab and React DevTools Profiler to find bottlenecks. Optimize images with proper formats and lazy loading. Code splitting reduces initial bundle size. Tree shaking removes unused code. Measure first, optimize second. Premature optimization wastes time. Profile to find real problems.

Why It Matters:

  • User experience depends on speed
  • Slow apps lose users
  • Performance affects SEO
  • Mobile devices are slower
  • Every millisecond matters
  • Professional apps are fast
  • Optimization is expected

Key Concepts:

  • React re-rendering behavior
  • React.memo prevents re-renders
  • useMemo caches calculations
  • useCallback caches functions
  • Lazy loading with React.lazy
  • Code splitting with dynamic imports
  • List virtualization
  • Debouncing and throttling

What You’ll Build:

  • Optimized component renders
  • Memoized expensive calculations
  • Lazy loaded routes
  • Virtualized lists
  • Debounced search
  • Code-split application
  • Performance monitoring

Time Investment: Understanding concepts takes 2–3 hours. Applying optimizations is ongoing.

Prerequisites:

  • React fundamentals (Post 11)
  • Hooks knowledge (Posts 12–14)
  • Component patterns
  • Chrome DevTools basics

FULL DETAILED ARTICLE

Understanding Re-renders

Get Yakhilesh’s stories in your inbox

Join Medium for free to get updates from this writer.

React re-renders when:

// 1. State changes
function Counter() {
const [count, setCount] = useState(0);

// Re-renders when count changes
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
// 2. Props change
function Child({ value }) {
// Re-renders when parent passes different value
return <div>{value}</div>;
}
// 3. Parent re-renders
function Parent() {
const [count, setCount] = useState(0);

return (
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<Child /> {/* Re-renders even though it has no props */}
</div>
);
}
// 4. Context value changes
const ThemeContext = createContext();
function ThemedButton() {
const theme = useContext(ThemeContext);
// Re-renders when context value changes
return <button className={theme}>Click me</button>;
}

The problem:

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

return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<ExpensiveComponent /> {/* Re-renders unnecessarily */}
<AnotherExpensiveComponent /> {/* Re-renders unnecessarily */}
</div>
);
}
// Every time count changes, ALL children re-render
// Even though they don't use count

React.memo

Prevent re-renders when props haven’t changed:

// Without React.memo - always re-renders
function ExpensiveComponent({ data }) {
console.log('Rendering ExpensiveComponent');
// Expensive calculations...
return <div>{data}</div>;
}
// With React.memo - only re-renders when props change
const ExpensiveComponent = React.memo(function ExpensiveComponent({ data }) {
console.log('Rendering ExpensiveComponent');
return <div>{data}</div>;
});
// Or as arrow function
const ExpensiveComponent = React.memo(({ data }) => {
console.log('Rendering ExpensiveComponent');
return <div>{data}</div>;
});
// Usage
function Parent() {
const [count, setCount] = useState(0);
const [data] = useState('static data');

return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<ExpensiveComponent data={data} />
{/* Only re-renders if data changes, not when count changes */}
</div>
);
}

Custom comparison function:

const User = React.memo(
({ user }) => {
return <div>{user.name}</div>;
},
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
// Return false if props are different (re-render)
return prevProps.user.id === nextProps.user.id;
}
);

The catch with React.memo:

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

// Problem: New object on every render
const user = { name: 'John', age: 30 };

return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<User user={user} />
{/* React.memo won't help - user object is new every time */}
</div>
);
}
// Solution: useMemo
function Parent() {
const [count, setCount] = useState(0);

const user = useMemo(() => ({ name: 'John', age: 30 }), []);

return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<User user={user} />
{/* Now React.memo works - same user object */}
</div>
);
}

useMemo

Cache expensive calculations:

function ExpensiveCalculation({ numbers }) {
// Without useMemo - recalculates on every render
const sum = numbers.reduce((acc, n) => acc + n, 0);

return <div>Sum: {sum}</div>;
}
// With useMemo - only recalculates when numbers change
function ExpensiveCalculation({ numbers }) {
const sum = useMemo(() => {
console.log('Calculating sum...');
return numbers.reduce((acc, n) => acc + n, 0);
}, [numbers]);

return <div>Sum: {sum}</div>;
}
// Real-world example
function UserList({ users, searchTerm }) {
// Filter only when users or searchTerm changes
const filteredUsers = useMemo(() => {
console.log('Filtering users...');
return users.filter(user =>
user.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [users, searchTerm]);

return (
<ul>
{filteredUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}

When to use useMemo:

// Good use case - expensive calculation
const sortedData = useMemo(() => {
return data.slice().sort((a, b) => a.value - b.value);
}, [data]);
// Good use case - referential equality for React.memo
const style = useMemo(() => ({ color: 'red', fontSize: 16 }), []);
// Bad use case - simple calculation (overhead not worth it)
const doubled = useMemo(() => value * 2, [value]); // Don't do this
const doubled = value * 2; // Just do this

useCallback

Prevent function recreation:

// Without useCallback - new function every render
function Parent() {
const [count, setCount] = useState(0);

const handleClick = () => {
console.log('Clicked');
};

return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<Child onClick={handleClick} />
{/* New handleClick function every render breaks React.memo */}
</div>
);
}
// With useCallback - same function reference
function Parent() {
const [count, setCount] = useState(0);

const handleClick = useCallback(() => {
console.log('Clicked');
}, []);

return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<Child onClick={handleClick} />
{/* Same handleClick reference preserves React.memo */}
</div>
);
}
const Child = React.memo(({ onClick }) => {
console.log('Child rendering');
return <button onClick={onClick}>Click me</button>;
});

With dependencies:

function SearchInput({ onSearch }) {
const [query, setQuery] = useState('');

const handleSearch = useCallback(() => {
onSearch(query);
}, [query, onSearch]); // Recreate when query or onSearch changes

return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<button onClick={handleSearch}>Search</button>
</div>
);
}

Lazy Loading and Code Splitting

Split code into smaller bundles:

// Without lazy loading - everything in one bundle
import Home from './pages/Home';
import About from './pages/About';
import Contact from './pages/Contact';
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
);
}
// With lazy loading - code split by route
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Contact = lazy(() => import('./pages/Contact'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</Suspense>
);
}
// Each route loads only when visited

Lazy loading components:

const HeavyChart = lazy(() => import('./HeavyChart'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);

return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
{showChart && (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
)}
</div>
);
}

Preloading:

const HeavyChart = lazy(() => import('./HeavyChart'));
// Preload on hover
function Dashboard() {
const preload = () => {
import('./HeavyChart'); // Start loading
};

return (
<button onMouseEnter={preload} onClick={() => setShow(true)}>
Show Chart
</button>
);
}

List Virtualization

Render only visible items:

// Without virtualization - renders all 10,000 items
function LongList({ items }) {
return (
<div>
{items.map(item => (
<div key={item.id} style={{ height: 50 }}>
{item.name}
</div>
))}
</div>
);
}
// With react-window - renders only ~20 visible items
import { FixedSizeList } from 'react-window';
function LongList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
);

return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}

Variable size items:

import { VariableSizeList } from 'react-window';
function LongList({ items }) {
const getItemSize = index => {
return items[index].height; // Different height per item
};

return (
<VariableSizeList
height={600}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
>
{Row}
</VariableSizeList>
);
}

Debouncing and Throttling

Reduce expensive operations:

// Without debounce - API call on every keystroke
function SearchInput() {
const [query, setQuery] = useState('');

const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
fetchResults(value); // Called on every keystroke!
};

return <input value={query} onChange={handleChange} />;
}
// With debounce - API call after user stops typing
import { useMemo } from 'react';
import debounce from 'lodash/debounce';
function SearchInput() {
const [query, setQuery] = useState('');

const debouncedFetch = useMemo(
() => debounce((value) => {
fetchResults(value);
}, 300),
[]
);

const handleChange = (e) => {
const value = e.target.value;
setQuery(value);
debouncedFetch(value); // Called 300ms after typing stops
};

return <input value={query} onChange={handleChange} />;
}
// Custom hook
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => clearTimeout(timer);
}, [value, delay]);

return debouncedValue;
}
// Usage
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);

useEffect(() => {
if (debouncedQuery) {
fetchResults(debouncedQuery);
}
}, [debouncedQuery]);

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

Throttle for scroll events:

import throttle from 'lodash/throttle';
function ScrollComponent() {
const handleScroll = useMemo(
() => throttle(() => {
console.log('Scrolling...');
// Heavy operation
}, 100),
[]
);

useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]);

return <div>Scroll me</div>;
}

Image Optimization

// Lazy load images
function LazyImage({ src, alt }) {
return (
<img
src={src}
alt={alt}
loading="lazy" // Native lazy loading
/>
);
}
// Responsive images
function ResponsiveImage({ src, alt }) {
return (
<img
src={src}
alt={alt}
srcSet={`
${src}-small.jpg 300w,
${src}-medium.jpg 600w,
${src}-large.jpg 1200w
`}
sizes="(max-width: 600px) 300px, (max-width: 1200px) 600px, 1200px"
/>
);
}
// WebP with fallback
function OptimizedImage({ src, alt }) {
return (
<picture>
<source srcSet={`${src}.webp`} type="image/webp" />
<source srcSet={`${src}.jpg`} type="image/jpeg" />
<img src={`${src}.jpg`} alt={alt} />
</picture>
);
}

Profiling and Measuring

React DevTools Profiler:

// Wrap components to measure
import { Profiler } from 'react';
function onRenderCallback(
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) {
console.log(`${id} took ${actualDuration}ms to render`);
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<MyComponent />
</Profiler>
);
}

Chrome Performance tab:

// Record performance
performance.mark('component-start');
// Component renders
performance.mark('component-end');
performance.measure('component', 'component-start', 'component-end');
// Get measurements
const measures = performance.getEntriesByType('measure');
console.log(measures);

Bundle Analysis

# Install
npm install --save-dev webpack-bundle-analyzer
# Analyze bundle
npm run build
npx webpack-bundle-analyzer build/bundle-stats.json

Common Patterns

Pattern 1: Windowing Large Lists

import { FixedSizeList as List } from 'react-window';
const VirtualList = ({ items }) => (
<List
height={400}
itemCount={items.length}
itemSize={35}
>
{({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
)}
</List>
);

Pattern 2: Memoized Context Value

const ThemeContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');

// Memoize context value to prevent re-renders
const value = useMemo(
() => ({ theme, setTheme }),
[theme]
);

return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}

Pattern 3: Optimized Event Handlers

function OptimizedList({ items, onItemClick }) {
// Single handler for all items
const handleClick = useCallback((e) => {
const id = e.target.dataset.id;
onItemClick(id);
}, [onItemClick]);

return (
<ul onClick={handleClick}>
{items.map(item => (
<li key={item.id} data-id={item.id}>
{item.name}
</li>
))}
</ul>
);
}

Common Mistakes

Mistake 1: Premature Optimization

// Bad - optimizing before measuring
const Component = React.memo(({ value }) => {
const doubled = useMemo(() => value * 2, [value]);
const handleClick = useCallback(() => {}, []);
// Unnecessary complexity
});
// Good - optimize when needed
const Component = ({ value }) => {
return <div>{value * 2}</div>;
};

Mistake 2: Missing Dependencies

// Bad - missing dependencies
const memoized = useMemo(() => {
return data.filter(item => item.category === category);
}, [data]); // Missing category!
// Good - all dependencies
const memoized = useMemo(() => {
return data.filter(item => item.category === category);
}, [data, category]);

Mistake 3: Overusing React.memo

// Bad - memoizing everything
const Button = React.memo(({ onClick }) => <button onClick={onClick}>Click</button>);
const Text = React.memo(({ children }) => <span>{children}</span>);
const Icon = React.memo(({ name }) => <i className={name} />);
// Good - memoize expensive components
const ExpensiveChart = React.memo(({ data }) => {
// Complex rendering
});

Performance Checklist

1. Measure first
// Use React DevTools Profiler
// Use Chrome Performance tab
// Use Lighthouse
2. Optimize renders
// React.memo for expensive components
// useMemo for expensive calculations
// useCallback for event handlers
3. Code splitting
// React.lazy for routes
// Dynamic imports for heavy components
4. Virtualize lists
// react-window for long lists
// Infinite scroll for data
5. Optimize assets
// Lazy load images
// Use WebP format
// Compress images
6. Debounce expensive operations
// API calls
// Search queries
// Scroll handlers
7. Bundle optimization
// Tree shaking
// Code splitting
// Analyze bundle size

Summary: Your Performance Checklist

Prevent Re-renders:

  • React.memo for components
  • useMemo for calculations
  • useCallback for functions
  • Split context providers

Code Splitting:

  • React.lazy for routes
  • Dynamic imports
  • Preload on hover

Lists:

  • Virtualize with react-window
  • Pagination for large data
  • Infinite scroll

Expensive Operations:

  • Debounce user input
  • Throttle scroll handlers
  • Cache API responses

Measure:

  • React DevTools Profiler
  • Chrome Performance tab
  • Lighthouse scores
  • Bundle analyzer

Practice Challenges

Optimize these scenarios:

Challenge 1: Slow List Optimize 10,000 item list with filtering and sorting.

Challenge 2: Search Component Add debouncing and memoization to search.

Challenge 3: Dashboard Code split heavy charts and tables.

Challenge 4: Image Gallery Lazy load images with virtualization.

Challenge 5: Form Performance Optimize complex form with many inputs.

You are 63 posts into the 75-post series. React performance optimization is now in your toolkit.

--

--

Yakhilesh
Yakhilesh

Written by Yakhilesh

I write about work, learning, and the things we overcomplicate. Clear thinking, honest notes from real experience. No hype. akhilesh.pages.dev