Follow Us

CodeWithSabir

  • Contact Us
  • Privacy Policy
  • About
  • Terms & Conditions

All Rights Reserved © 2026

  • Light
  • Dark
Web Development

React Performance Optimization Techniques That Actually Work

Sabir Soft
Sabir Lkhaloufi
  • December 20, 2025
  • 4 min read

React Performance Optimization Techniques That Actually Work

Most React performance advice falls into one of two categories: either too theoretical to apply, or suggesting optimizations for problems you don't have. This guide focuses on the techniques that make a measurable difference in real applications, with clear explanations of when each one actually helps.

The golden rule before optimizing anything: measure first. Use React DevTools Profiler and your browser's Performance tab to find actual bottlenecks before applying any of these techniques.

Understanding Re-renders First

React re-renders a component when:

  1. Its state changes
  2. Its props change
  3. Its parent re-renders (even if props didn't change)

Rule 3 is why performance issues emerge — a parent state update can cascade re-renders through dozens of components even when none of them need to update.

// Every time ParentComponent re-renders (e.g., typing in a search input),
// ExpensiveList and SomethingUnrelated BOTH re-render unnecessarily
function ParentComponent() {
  const [search, setSearch] = useState('')
  
  return (
    <div>
      <input value={search} onChange={e => setSearch(e.target.value)} />
      <ExpensiveList />        {/* Re-renders on every keystroke */}
      <SomethingUnrelated />   {/* Also re-renders on every keystroke */}
    </div>
  )
}

React.memo: Preventing Unnecessary Re-renders

React.memo wraps a component and skips re-rendering if props haven't changed (shallow comparison).

// Without memo — re-renders every time parent re-renders
function UserCard({ user }: { user: User }) {
  return (
    <div>
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
    </div>
  )
}
 
// With memo — only re-renders when user prop actually changes
const UserCard = React.memo(function UserCard({ user }: { user: User }) {
  return (
    <div>
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
    </div>
  )
})

When React.memo helps: components that receive stable props but have expensive parents.

When it doesn't help: when props change on every render anyway, or when the component is cheap to render. Don't memoize everything — the overhead of the comparison can exceed the cost of the re-render.

useCallback: Stable Function References

The problem: when you pass a function as a prop, a new function is created on every render, which breaks React.memo:

// BAD: handleDelete is a new function on every render
// UserCard's memo won't help because props keep changing
function UserList({ users }: { users: User[] }) {
  function handleDelete(id: string) {
    // delete logic
  }
 
  return users.map(user => (
    <UserCard key={user.id} user={user} onDelete={handleDelete} />
  ))
}
// GOOD: useCallback preserves the same function reference
function UserList({ users }: { users: User[] }) {
  const handleDelete = useCallback((id: string) => {
    // delete logic
  }, []) // Empty deps: function never changes
 
  return users.map(user => (
    <UserCard key={user.id} user={user} onDelete={handleDelete} />
  ))
}

When useCallback helps: only when passing functions to memoized components. Without React.memo on the child, useCallback does nothing useful.

useMemo: Caching Expensive Calculations

// Without useMemo — this filter runs on every render, even when data hasn't changed
function ProductList({ products, search }: Props) {
  const filtered = products.filter(p =>
    p.name.toLowerCase().includes(search.toLowerCase())
  )
  // ...
}
 
// With useMemo — only re-computes when products or search changes
function ProductList({ products, search }: Props) {
  const filtered = useMemo(
    () => products.filter(p => p.name.toLowerCase().includes(search.toLowerCase())),
    [products, search]
  )
  // ...
}

When useMemo helps: genuinely expensive computations (sorting thousands of items, complex data transformations). For simple filters on small arrays, useMemo adds overhead without benefit.

The mental model: does this computation take more than ~1ms? Profile it. If not, don't memoize.

Code Splitting with Dynamic Imports

Not all performance issues are re-render issues. Large bundle sizes cause slow initial loads. Code splitting lets you load components only when needed:

import { lazy, Suspense } from 'react'
 
// Only loads RichTextEditor when actually rendered
const RichTextEditor = lazy(() => import('./RichTextEditor'))
const Analytics = lazy(() => import('./Analytics'))
 
function PostEditor() {
  const [showEditor, setShowEditor] = useState(false)
 
  return (
    <div>
      <button onClick={() => setShowEditor(true)}>Open Editor</button>
      
      {showEditor && (
        <Suspense fallback={<div>Loading editor...</div>}>
          <RichTextEditor />
        </Suspense>
      )}
    </div>
  )
}

A rich text editor might add 200KB to your bundle. If most users never open it, that's 200KB downloaded for nothing.

Route-level code splitting is automatic in Next.js. For large optional components (modals, editors, complex charts), use lazy explicitly.

Virtualization for Long Lists

Rendering 10,000 list items creates 10,000 DOM nodes. The browser can't handle that efficiently. Virtualization renders only the items currently visible in the viewport:

import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
 
function VirtualizedList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null)
 
  const rowVirtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 60, // Estimated row height in px
  })
 
  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative' }}>
        {rowVirtualizer.getVirtualItems().map(virtualItem => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            <ItemRow item={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

When to use: lists with more than ~100 items where scrolling performance is important.

State Colocation: The Simplest Win

Before reaching for memoization, ask: is the state in the right place?

// BAD: search state in parent causes full re-render on every keystroke
function Page() {
  const [search, setSearch] = useState('')  // State here
 
  return (
    <div>
      <SearchInput value={search} onChange={setSearch} />
      <ExpensiveProductGrid />  {/* Re-renders on every keystroke */}
    </div>
  )
}
 
// GOOD: move search state to SearchInput — ExpensiveProductGrid never re-renders
function SearchInput() {
  const [search, setSearch] = useState('')  // State lives here
  return <input value={search} onChange={e => setSearch(e.target.value)} />
}
 
function Page() {
  return (
    <div>
      <SearchInput />
      <ExpensiveProductGrid />  {/* Never re-renders */}
    </div>
  )
}

State colocation is often more effective than memoization and requires zero API knowledge.

Common Mistakes

1. Premature optimization. Memoizing every component before profiling. This adds complexity without benefit and can actually slow things down.

2. Forgetting dependency arrays. useMemo and useCallback with wrong dependencies cause stale values or defeat the purpose by running on every render.

3. Using state for derived data. If a value can be computed from existing state, compute it — don't store it separately:

// BAD: derived state
const [items, setItems] = useState([])
const [count, setCount] = useState(0)  // Derived from items.length
 
// GOOD: compute it
const [items, setItems] = useState([])
const count = items.length  // Always in sync, zero extra state

4. Ignoring key prop. Keys help React identify which items changed. Wrong keys cause unnecessary unmount/remount cycles.

Key Takeaways

  • Measure before optimizing — don't guess where the bottleneck is
  • React.memo only helps when props are stable; combine with useCallback for function props
  • useMemo is for genuinely expensive computations — not simple transforms
  • State colocation is often more effective than memoization
  • Code split large optional components with React.lazy
  • Virtualize lists with more than 100 items
  • The simplest performance optimization is often lifting state down to where it belongs
Popular Blogs
Claude AI vs ChatGPT: An Honest Comparison for Developers
  • April 28, 2026
AI Tools Every Developer Should Be Using in 2026
  • April 20, 2026
Using the Claude API in Real Projects: A Practical Developer Guide
  • April 15, 2026
Prompt Engineering for Developers: Write Prompts That Actually Work
  • April 10, 2026
Categories
AIDevOpsNext.jsMobile DevelopmentWeb Development

Related Posts

Web Development
Building REST APIs with Node.js and Express: The Complete Guide
Sabir Khaloufi·Dec 25, 2025
Web Development
TypeScript Best Practices for React Developers
Sabir Khaloufi·Dec 15, 2025
Web Development
Understanding JWT Authentication in Modern Web Apps
Sabir Khaloufi·Dec 10, 2025