React Performance Optimization: A Practical Guide for Junior Developers
Most junior developers discover React performance optimization in a frustrating way: the app feels fine at first, then a search box starts lagging, a large list stutters, or clicking one button seems to re-render half the page. At that point, everything looks suspicious, so people start adding memoization everywhere.
That usually makes the code harder to read without fixing the real bottleneck. The goal is not to stop every render. React is supposed to re-render. The goal is to stop the expensive renders that happen for no useful reason.
This guide gives you a practical useMemo useCallback tutorial, shows how React.memo fits into the picture, and explains the most reliable React slow rendering fix: measure first, then optimize only the parts that are actually slow.
Why React apps get slow: re-renders explained simply
A React render is just React calling your component function again to figure out what the UI should look like now. That is normal. The problem starts when a render triggers expensive work every time: filtering a huge array, sorting data, building a big object, or causing child components to render again even though nothing meaningful changed for them.
A parent re-render does not automatically mean you have a bug. It becomes a performance issue when the render chain is large or when one component does heavy work on every pass. Junior devs often misread this and think, "React is slow." Usually, the problem is that the component tree is doing repeated work it could skip.
So the mental model is simple: state changes cause renders, renders can cascade downward, and slow code inside that path is what users feel.
Slow vs fast: the most common pattern
Here is a small example. The slow version recalculates a filtered list every time the parent renders, even when the list and the filter text did not change.
Slow version
function ProductList({ products, filter }) {
const visibleProducts = products.filter((product) =>
product.name.toLowerCase().includes(filter.toLowerCase())
);
return (
<ul>
{visibleProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}That is completely fine for tiny lists. But if the list is large and the parent re-renders often, this repeated filtering becomes noticeable.
Faster version with useMemo
import { useMemo } from "react";
function ProductList({ products, filter }) {
const visibleProducts = useMemo(() => {
return products.filter((product) =>
product.name.toLowerCase().includes(filter.toLowerCase())
);
}, [products, filter]);
return (
<ul>
{visibleProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}useMemo: when and how to use it
useMemomemoizes a computed value. In plain English, it tells React, "reuse the previous result unless these dependencies changed." That helps when the calculation is expensive and when the dependencies stay stable across many renders.
Good uses include filtering big lists, sorting data, or building derived values that are expensive enough to matter. Bad uses include wrapping every small calculation just because you can. Memoization itself has a cost, so it only helps when it avoids more work than it adds.
A practical rule: if the calculation is cheap, skip useMemo. If it is expensive and runs on many unrelated re-renders, memoization is worth testing.
useCallback: when and how to use it
useCallback memoizes a function reference, not the result of a function. This matters because React sees a brand-new inline function on every render. If you pass that function to a memoized child, the child will still re-render because the prop reference changed.
Slow version
function Dashboard({ products }) {
const [count, setCount] = useState(0);
const handleSelect = (id) => {
console.log("Selected product", id);
};
return (
<>
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
<ProductTable products={products} onSelect={handleSelect} />
</>
);
}Every click creates a new handleSelect function. If ProductTable is memoized, that changing function prop still breaks the optimization.
Faster version with useCallback
import { useCallback, useState } from "react";
function Dashboard({ products }) {
const [count, setCount] = useState(0);
const handleSelect = useCallback((id) => {
console.log("Selected product", id);
}, []);
return (
<>
<button onClick={() => setCount((current) => current + 1)}>
Clicked {count} times
</button>
<ProductTable products={products} onSelect={handleSelect} />
</>
);
}Do not use useCallback for every handler by default. It is most useful when a stable function reference helps a child skip re-renders, or when the function is part of another hook dependency list and you need predictable identity.
React.memo: prevent child re-renders when props did not change
React.memo wraps a component and tells React to skip re-rendering it if its props are shallowly equal to the previous render. This is where many performance fixes come together. useMemo keeps a derived value stable, useCallback keeps a function prop stable, and React.memo gives the child permission to skip work when those props have not changed.
import { memo } from "react";
const ProductTable = memo(function ProductTable({ products, onSelect }) {
console.log("ProductTable rendered");
return (
<ul>
{products.map((product) => (
<li key={product.id}>
<button onClick={() => onSelect(product.id)}>
{product.name}
</button>
</li>
))}
</ul>
);
});This only works if the props are stable. If you keep passing a freshly created array or inline function on every parent render, the child still re-renders. That is why these tools are usually combined rather than used in isolation.
The golden rule: profile before you optimize
The best junior-level performance habit is not memorizing hooks. It is opening React DevTools Profiler before changing code. Record an interaction, look at which components re-rendered, and check which ones took the most time. That tells you whether the issue is a large render tree, an expensive computation, or a child component re-rendering from unstable props.
Without profiling, you are guessing. Guessing leads to defensive memoization everywhere, which can make the codebase harder to understand and sometimes slower. Profiling turns performance work from superstition into engineering.
If you want a checklist, use this order: reproduce the lag, record it in React DevTools, identify the hot component, then test one focused optimization and measure again.
Build the full junior React toolkit
Performance is easier once your fundamentals are solid. If you want to round out the rest of the cluster, read How to get unstuck in React, Top 5 React mistakes junior developers make, The Complete useEffect Guide for Junior React Developers, and React State Management Explained.
Need help fixing a slow component?
If your app feels slow and you are not sure whether the problem is re-renders, state structure, or an effect loop, book a working session. We will profile the real component, explain the bottleneck, and help you ship the fix without turning the whole codebase into a memoization puzzle.
Book a React mentoring session at https://mento.nanocorp.app/book