React.js Performance Optimization Tips Every Developer Should Know

5 0
React.js Performance Optimization Tips Every Developer Should Know

React is fast by default — but as your application grows, performance problems creep in. Slow re-renders, bloated bundles, sluggish lists, and unnecessary API calls can make a React app feel laggy even on powerful hardware.

The good news? Most React performance issues come from a small set of common mistakes — and fixing them is straightforward once you know what to look for. In this guide, we’ll cover the most effective React performance optimization techniques with real code examples you can apply today.


1. Understand Re-renders First

Before optimizing anything, you need to understand when React re-renders a component. A component re-renders when:

  • Its own state changes
  • Its parent re-renders (even if props didn’t change)
  • A context it consumes changes

Most performance problems in React come from unnecessary re-renders — components re-rendering when their output wouldn’t actually change. The techniques below all target this root cause.

Install React DevTools and use the Profiler tab to visualise which components are re-rendering and why — always profile before optimizing.


2. React.memo — Prevent unnecessary re-renders

React.memo is a higher-order component that wraps a component and skips re-rendering it if its props haven’t changed.

// Without React.memo — re-renders every time parent renders
function UserCard({ name, avatar }) {
  console.log('UserCard rendered');
  return (
    <div className="card">
      <img src={avatar} alt={name} />
      <h3>{name}</h3>
    </div>
  );
}

// With React.memo — only re-renders when name or avatar changes
const UserCard = React.memo(function UserCard({ name, avatar }) {
  console.log('UserCard rendered');
  return (
    <div className="card">
      <img src={avatar} alt={name} />
      <h3>{name}</h3>
    </div>
  );
});

When to use it: Components that receive the same props frequently but are expensive to render — list items, cards, charts, or any component in a frequently-updating parent.

When NOT to use it: On every component blindly. React.memo has its own overhead. If a component is cheap to render or its props change constantly, memo adds cost without benefit.


3. useMemo — Cache expensive calculations

useMemo memorises the result of an expensive computation and only recalculates it when its dependencies change.

import { useState, useMemo } from 'react';

function ProductList({ products, searchTerm, sortBy }) {
  // Without useMemo — runs on every render, even unrelated state changes
  const processedProducts = products
    .filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()))
    .sort((a, b) => sortBy === 'price' ? a.price - b.price : a.name.localeCompare(b.name));

  // With useMemo — only recalculates when products, searchTerm, or sortBy changes
  const processedProducts = useMemo(() => {
    return products
      .filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()))
      .sort((a, b) => sortBy === 'price' ? a.price - b.price : a.name.localeCompare(b.name));
  }, [products, searchTerm, sortBy]);

  return (
    <ul>
      {processedProducts.map(p => <li key={p.id}>{p.name} - ₹{p.price}</li>)}
    </ul>
  );
}

Use useMemo for: Filtering or sorting large arrays, expensive mathematical calculations, or derived data that involves significant processing.


4. useCallback — Stable function references

Every render creates new function instances. When you pass a function as a prop to a memoised child, the child re-renders anyway because the function reference is new. useCallback fixes this.

import { useState, useCallback, memo } from 'react';

// Child component — wrapped in memo
const TodoItem = memo(function TodoItem({ todo, onDelete, onToggle }) {
  console.log(`Rendering: ${todo.text}`);
  return (
    <li>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => onToggle(todo.id)}
      />
      {todo.text}
      <button onClick={() => onDelete(todo.id)}>Delete</button>
    </li>
  );
});

// Parent component
function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Learn React', done: false },
    { id: 2, text: 'Build a project', done: false },
  ]);
  const [filter, setFilter] = useState('all');

  // Without useCallback — new function every render, memo on child is useless
  // With useCallback — same function reference unless setTodos changes
  const handleDelete = useCallback((id) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }, []); // empty deps — setTodos is stable

  const handleToggle = useCallback((id) => {
    setTodos(prev =>
      prev.map(todo => todo.id === id ? { ...todo, done: !todo.done } : todo)
    );
  }, []);

  return (
    <>
      <select value={filter} onChange={e => setFilter(e.target.value)}>
        <option value="all">All</option>
        <option value="done">Done</option>
      </select>
      <ul>
        {todos.map(todo => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onDelete={handleDelete}
            onToggle={handleToggle}
          />
        ))}
      </ul>
    </>
  );
}

Key insight: useCallback only makes sense when the receiving component is wrapped in React.memo. They work as a pair.


5. Code Splitting with React.lazy — Load only what’s needed

By default, your entire React app is bundled into one JavaScript file. Users download all your code even if they never visit most pages. Code splitting fixes this by loading components only when they’re needed.

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// Instead of eager imports:
// import Dashboard from './pages/Dashboard';
// import Analytics from './pages/Analytics';
// import Settings from './pages/Settings';

// Lazy load — each page is a separate chunk
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div className="loading">Loading...</div>}>
        <Routes>
          <Route path="/" element={<Dashboard />} />
          <Route path="/analytics" element={<Analytics />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

Now each page is downloaded only when the user navigates to it. Your initial bundle shrinks dramatically — faster first load, better Lighthouse scores.

Also lazy-load heavy components: Modals, charts, rich text editors, image galleries — anything large that isn’t visible on initial render.

// Lazy load a heavy chart component
const RevenueChart = lazy(() => import('./components/RevenueChart'));

function Dashboard() {
  const [showChart, setShowChart] = useState(false);

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

6. List Virtualisation — Render only visible items

Rendering a list of 10,000 items creates 10,000 DOM nodes — even if only 20 are visible on screen. Virtualisation renders only the visible items, dramatically improving performance for long lists.

npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

function VirtualList({ items }) {
  const parentRef = useRef(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50, // estimated row height in px
  });

  return (
    <div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() + 'px', position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: virtualRow.start + 'px',
              height: virtualRow.size + 'px',
              width: '100%',
            }}
          >
            {items[virtualRow.index].name}
          </div>
        ))}
      </div>
    </div>
  );
}

Use virtualisation when: Your list has more than 100 items, especially if each item contains images or complex markup.


7. Avoid Anonymous Functions in JSX

Defining functions inline in JSX creates a new function on every render — breaking memoisation and causing unnecessary re-renders in children.

// Bad — new function created on every render
<button onClick={() => handleDelete(item.id)}>Delete</button>

// Better — define outside JSX or use useCallback
const handleDeleteItem = useCallback(() => {
  handleDelete(item.id);
}, [item.id]);

<button onClick={handleDeleteItem}>Delete</button>

// Also bad — inline object creates new reference every render
<Component style={{ margin: '10px' }} />

// Better — define outside the component or use useMemo
const cardStyle = { margin: '10px' };
<Component style={cardStyle} />

8. Optimise Images

Images are often the biggest performance bottleneck — even in React apps. A few simple steps make a huge difference:

// Use loading="lazy" for images below the fold
<img src="/product.jpg" alt="Product" loading="lazy" />

// Specify width and height to prevent layout shift
<img src="/hero.jpg" alt="Hero" width={1200} height={630} loading="eager" />

// Use next/image in Next.js — handles everything automatically
import Image from 'next/image';
<Image src="/hero.jpg" alt="Hero" width={1200} height={630} priority />

Also:

  • Use WebP format — 25–35% smaller than JPEG with same quality
  • Use responsive images with srcset — serve smaller images on mobile
  • Compress images before upload — use tools like Squoosh or TinyPNG

9. Debounce Expensive Operations

If you’re calling an API or running expensive code on every keystroke, you’re doing far more work than needed. Debouncing delays the operation until the user stops typing.

import { useState, useEffect } from 'react';

function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer); // cleanup on value change
  }, [value, delay]);

  return debouncedValue;
}

// Usage in search
function SearchBar() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 400);

  useEffect(() => {
    if (debouncedQuery) {
      // API call only fires 400ms after user stops typing
      searchProducts(debouncedQuery);
    }
  }, [debouncedQuery]);

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

10. Avoid Prop Drilling with Context or State Libraries

Passing props through many layers of components causes every component in the chain to re-render when data changes — even components that don’t use the data.

// Bad — UserContext change re-renders the entire tree
const UserContext = createContext();

// Better — split context by concern
const UserContext = createContext();        // user identity
const ThemeContext = createContext();       // theme preference
const NotificationsContext = createContext(); // notifications only

// Consumers only re-render when their specific context changes

For complex global state, consider lightweight state managers like Zustand or Jotai — they provide fine-grained subscriptions so components only re-render when the exact slice of state they use changes.

// Zustand — simple and performant global state
import { create } from 'zustand';

const useStore = create((set) => ({
  user: null,
  cart: [],
  setUser: (user) => set({ user }),
  addToCart: (item) => set((state) => ({ cart: [...state.cart, item] })),
}));

// Component only re-renders when 'cart' changes — not when 'user' changes
function CartBadge() {
  const cart = useStore((state) => state.cart);
  return <span>{cart.length}</span>;
}

Quick Reference — Performance Checklist

TechniqueWhat it solvesWhen to apply
React.memoUnnecessary re-rendersExpensive child components with stable props
useMemoExpensive calculationsHeavy filtering, sorting, derived data
useCallbackUnstable function refsFunctions passed to memoised children
React.lazyLarge initial bundleRoute-level and heavy components
VirtualisationLong list performanceLists with 100+ items
DebounceOver-firing eventsSearch inputs, resize, scroll handlers
Image optimisationSlow page loadAny app with images
Split contextUnnecessary context re-rendersLarge apps with global state

Final Thoughts

React performance optimization isn’t about applying every technique to every component — it’s about identifying the actual bottleneck and fixing that specific problem. Profile first, optimize second.

In practice, the highest-impact changes are almost always: code splitting your routes with React.lazy, wrapping expensive list items in React.memo, and debouncing your search inputs. Those three alone will noticeably speed up most React apps.

If you’re not yet comfortable with useMemo and useCallback, revisit the complete React Hooks guide on this blog — it covers both hooks in detail with focused examples. And if you’re building a React frontend that talks to a backend, the React vs Next.js guide will help you decide which architecture fits your project best.

Happy coding! 🚀

ByDev24

ByDev24

https://www.bydev24.com/

ByDev24 is a creative technology company focused on web development, app development, UI design, and digital solutions. We share coding tutorials, development tips, and modern tech insights to help developers and businesses build better digital experiences.

0 comments

Leave a Reply

Your email address will not be published. Required fields are marked *