Follow AiTechWorlds on LinkedIn for professional AI content!Follow Now →
18 minLesson 12 of 33
Advanced React Patterns

React.memo & Avoiding Re-renders

React.memo & Avoiding Re-renders

Understanding when React re-renders is the foundation of React performance. Most "slow" React apps are actually rendering correctly — they just do more work than necessary. This lesson gives you the mental model to identify wasted renders and the tools to eliminate them.

When React Re-renders

A component re-renders when:

  1. Its own state changes
  2. Its parent re-renders (and passes new props)
  3. Context it consumes changes

Notice what's NOT on the list: the props themselves changing. By default, if a parent re-renders, ALL its children re-render, even if their props are identical.

function Counter() {
    const [count, setCount] = useState(0);
    
    console.log("Counter rendered");
    
    return (
        <div>
            <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
            <StaticContent />   {/* This re-renders on every count change! */}
        </div>
    );
}

function StaticContent() {
    console.log("StaticContent rendered");  // This logs on every click
    return <div>I never change</div>;
}

Every click on the counter re-renders StaticContent, even though it has no props and never changes.

React.memo

Wrap a component in React.memo to make it only re-render when its props change:

const StaticContent = React.memo(function StaticContent() {
    console.log("StaticContent rendered");  // Now only logs on mount
    return <div>I never change</div>;
});

React does a shallow comparison of all props. If every prop passes === equality, the component skips the render.

When React.memo Doesn't Help

Shallow comparison means reference equality for objects and functions:

function Parent() {
    const [count, setCount] = useState(0);
    
    // ❌ New object on every render — memo won't help
    const config = { theme: "dark", language: "en" };
    
    // ❌ New function on every render — memo won't help
    const handleClick = () => console.log("clicked");
    
    return <MemoizedChild config={config} onClick={handleClick} />;
}

To make React.memo effective, you need stable prop references:

function Parent() {
    const [count, setCount] = useState(0);
    
    // ✅ Stable reference — only changes if theme/language change
    const config = useMemo(() => ({ theme: "dark", language: "en" }), []);
    
    // ✅ Stable function reference
    const handleClick = useCallback(() => console.log("clicked"), []);
    
    return <MemoizedChild config={config} onClick={handleClick} />;
}

const MemoizedChild = React.memo(function MemoizedChild({ config, onClick }) {
    return <button onClick={onClick}>Theme: {config.theme}</button>;
});

The Stable setState Pattern

setState functions from useState are always stable — they never change identity between renders. You don't need useCallback to wrap functions that only call a setter:

function Parent() {
    const [items, setItems] = useState<string[]>([]);
    
    // ✅ setItems is stable — useCallback not needed here
    function addItem(item: string) {
        setItems(prev => [...prev, item]);
    }
    
    // But the function itself is recreated on every render
    // If you pass it to a memo'd component, you need useCallback:
    const stableAddItem = useCallback((item: string) => {
        setItems(prev => [...prev, item]);
    }, []);   // setItems is stable, so empty deps is correct
    
    return <MemoizedInput onAdd={stableAddItem} />;
}

State Colocation — The Better Optimization

Before reaching for React.memo, consider moving state closer to where it's used. When state lives at a higher level than necessary, it causes more components to re-render.

// ❌ Modal state in App causes everything to re-render on toggle
function App() {
    const [modalOpen, setModalOpen] = useState(false);
    
    return (
        <div>
            <ExpensiveList />     {/* Re-renders when modal opens/closes */}
            <ExpensiveChart />    {/* Re-renders when modal opens/closes */}
            <Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} />
            <button onClick={() => setModalOpen(true)}>Open</button>
        </div>
    );
}

// ✅ Extract the modal + trigger into its own component
function App() {
    return (
        <div>
            <ExpensiveList />    {/* No longer affected by modal state */}
            <ExpensiveChart />   {/* No longer affected by modal state */}
            <DeleteConfirmation />   {/* Modal state lives here */}
        </div>
    );
}

function DeleteConfirmation() {
    const [open, setOpen] = useState(false);   // State scoped to this component
    
    return (
        <>
            <button onClick={() => setOpen(true)}>Delete</button>
            <Modal isOpen={open} onClose={() => setOpen(false)} />
        </>
    );
}

No memo, no useCallback — just better structure. This is usually the right fix first.

Lifting Content Up / Children as Props

Another structural optimization: pass components as children instead of rendering them directly. Children of a component only re-render if their own props change — the parent's state changes don't affect them:

// ❌ ExpensiveChild re-renders when Parent state changes
function Parent() {
    const [count, setCount] = useState(0);
    return (
        <div>
            <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
            <ExpensiveChild />   {/* Re-renders on count change */}
        </div>
    );
}

// ✅ Children passed from above don't re-render with Parent
function Parent({ children }: { children: React.ReactNode }) {
    const [count, setCount] = useState(0);
    return (
        <div>
            <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
            {children}   {/* Not affected by count state */}
        </div>
    );
}

// App creates the child, Parent just slots it in
function App() {
    return (
        <Parent>
            <ExpensiveChild />   {/* Only re-renders if App re-renders */}
        </Parent>
    );
}

Lazy Loading Components

For large components not needed on initial load, use React.lazy:

import { lazy, Suspense } from "react";

// The import is deferred until this component is first rendered
const RichEditor = lazy(() => import("./RichEditor"));
const VideoPlayer = lazy(() => import("./VideoPlayer"));

function CourseEditor() {
    const [activeTab, setActiveTab] = useState("content");
    
    return (
        <div>
            <TabBar active={activeTab} onChange={setActiveTab} />
            
            <Suspense fallback={<div>Loading editor...</div>}>
                {activeTab === "content" && <RichEditor />}
                {activeTab === "preview" && <VideoPlayer />}
            </Suspense>
        </div>
    );
}

RichEditor and VideoPlayer are only downloaded when the user clicks that tab. The initial bundle is smaller. Page loads faster.

Performance Checklist

Before reaching for memoization:

  1. Check if it's actually slow — open React DevTools Profiler and measure
  2. Colocate state — move state down to where it's needed
  3. Use children as props — structural fix, no hooks needed
  4. Then add React.memo + useMemo + useCallback if specific components are measured to be slow

Premature optimization makes code harder to read. Measure, then optimize the things that actually matter.

Next lesson: Next.js 15 architecture overview — the full picture of how Next.js works.

📱

Get this course's notes on Telegram!

Free cheat sheets, summaries & practice exercises

Get Notes Free →
!