A React application can work perfectly and still feel slow.
Maybe typing into a search box causes the entire page to lag. Maybe a product list takes too long to render. Maybe users have to download a huge JavaScript bundle before they can interact with your application.
These are performance problems.
The good news is that React provides several tools and patterns to help us build responsive applications. The important part is knowing when to use them, why they work, and how to measure their impact.
In this article, we'll explore practical React performance optimization techniques, from understanding re-renders to memoization, list virtualization, code splitting, and responsive updates.
1. What Does Performance Actually Mean?
Performance is more than just making a page load quickly.
When evaluating a React application, consider several dimensions:
| Area | What it means |
|---|---|
| Initial load | How quickly the application becomes usable |
| Responsiveness | How quickly the application reacts to user input |
| Rendering | How efficiently React updates the interface |
| Bundle size | How much JavaScript users need to download |
| Memory usage | How much memory the application consumes |
| Network performance | How efficiently data and assets are requested |
For example:
- A page might load quickly but freeze when filtering 10,000 products.
- A page might render smoothly but download several megabytes of JavaScript.
- A search field might work correctly but feel sluggish because every keystroke triggers expensive work.
Good performance means improving the experience as a whole.
2. Measure Before You Optimize
One of the biggest mistakes developers make is optimizing code based on assumptions.
You see a component with several state variables and immediately add useMemo, useCallback, and React.memo.
But are those components actually causing a performance problem?
Measure first.
React Developer Tools Profiler
The React Developer Tools browser extension includes a Profiler.
It helps you understand:
- Which components rendered.
- How frequently they rendered.
- How long rendering took.
- Which updates caused components to render.
- Whether memoization prevented unnecessary work.
A useful workflow:
- Open your application.
- Open React Developer Tools.
- Select the Profiler tab.
- Start recording.
- Interact with the application.
- Stop recording.
- Inspect the components that rendered.
Look for components that render frequently or take unusually long.
Browser Performance Panel
The browser's Performance panel helps investigate work beyond React rendering.
It can reveal:
- JavaScript execution time.
- Long tasks.
- Layout and painting work.
- Event processing delays.
- Main-thread bottlenecks.
This is particularly useful when the application feels frozen or interactions are delayed.
Lighthouse
Lighthouse provides an overview of website performance and other quality metrics.
It can help identify:
- Large JavaScript bundles.
- Render-blocking resources.
- Image optimization opportunities.
- Loading performance problems.
- Accessibility and SEO issues.
These tools serve different purposes:
- React Profiler: Investigates React component rendering.
- Browser Performance: Investigates browser execution and rendering.
- Lighthouse: Audits overall page quality and loading performance.
Don't optimize blindly. Find the bottleneck first.
3. Understand How React Rendering Works
Before optimizing React, we need to understand what happens when a component renders.
Consider:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
When the button is clicked:
- The state update is scheduled.
- React calls the component function again.
- React calculates the next UI.
- React compares the result with the previous render.
- React commits the necessary changes to the DOM.
There are two important concepts:
Render phase: React calls components and calculates what the next UI should look like.
Commit phase: React applies the required changes to the DOM and performs related commit work.
A component rendering again does not necessarily mean the browser DOM changes.
What Triggers a Re-render?
Common causes include:
- The component's own state changes.
- Its parent renders again.
- A Context value it consumes changes.
- A subscribed external store reports an update.
For example:
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<Child />
</div>
);
}
function Child() {
console.log("Child rendered");
return <p>Hello from Child</p>;
}
Every time Parent renders, Child normally renders too.
Even though Child doesn't use count, it still participates in the render.
This is normal React behavior. It is not automatically a performance problem.
The goal is not to prevent every render. The goal is to avoid expensive, unnecessary work.
4. Avoid Keeping State Too High in the Component Tree
One common performance problem is placing state higher in the component tree than necessary.
Consider:
function App() {
const [search, setSearch] = useState("");
return (
<div>
<Header />
<SearchBox
value={search}
onChange={setSearch}
/>
<ProductList />
<Footer />
</div>
);
}
Every time the user types, App renders again.
That means its children may render again too.
If the search state is only needed by the search feature, consider moving it closer to where it is used.
function SearchSection() {
const [search, setSearch] = useState("");
return (
<>
<SearchBox
value={search}
onChange={setSearch}
/>
<ProductList search={search} />
</>
);
}
function App() {
return (
<div>
<Header />
<SearchSection />
<Footer />
</div>
);
}
Now typing doesn't directly cause App to render.
This pattern is called state locality.
A useful rule:
Keep state as close as possible to the components that need it.
This often provides more benefit than adding memoization everywhere.
5. React.memo: Prevent Unnecessary Child Renders
React provides memo to skip rendering a component when its props have not changed.
Example:
import { memo, useState } from "react";
const UserCard = memo(function UserCard({ user }) {
console.log("UserCard rendered");
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
});
function App() {
const [count, setCount] = useState(0);
const user = {
name: "Alex",
email: "alex@example.com",
};
return (
<div>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
<UserCard user={user} />
</div>
);
}
There is a problem.
Every time App renders, it creates a new object:
const user = {
name: "Alex",
email: "alex@example.com",
};
Even though the values are identical, the object has a new reference.
React's default memo comparison checks props using shallow comparison. The new object means the user prop is considered changed.
As a result, UserCard renders again.
We can solve this with useMemo:
import {
memo,
useMemo,
useState,
} from "react";
const UserCard = memo(function UserCard({ user }) {
console.log("UserCard rendered");
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
});
function App() {
const [count, setCount] = useState(0);
const user = useMemo(() => ({
name: "Alex",
email: "alex@example.com",
}), []);
return (
<div>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
<UserCard user={user} />
</div>
);
}
Now the user reference remains stable between renders, so memo can skip rendering UserCard when its other relevant conditions are unchanged.
When Should You Use React.memo?
Consider it when:
- A component renders frequently.
- Rendering is relatively expensive.
- Its props often remain unchanged.
- Profiling shows that skipping the render is beneficial.
Don't wrap every component in memo.
Memoization has its own costs, and it does not prevent renders caused by the component's own state or consumed Context changing.
6. useMemo: Cache Expensive Calculations
Sometimes the problem isn't rendering a child component.
Sometimes the component performs an expensive calculation on every render.
Consider:
function ProductList({ products, search }) {
const filteredProducts = products.filter((product) =>
product.name
.toLowerCase()
.includes(search.toLowerCase())
);
return (
<ul>
{filteredProducts.map((product) => (
<li key={product.id}>
{product.name}
</li>
))}
</ul>
);
}
If filtering is expensive and the component renders frequently, we may want to cache the result.
import { useMemo } from "react";
function ProductList({ products, search }) {
const filteredProducts = useMemo(() => {
return products.filter((product) =>
product.name
.toLowerCase()
.includes(search.toLowerCase())
);
}, [products, search]);
return (
<ul>
{filteredProducts.map((product) => (
<li key={product.id}>
{product.name}
</li>
))}
</ul>
);
}
React recalculates the filtered list when products or search changes.
Otherwise, it can reuse the previous result.
Important: useMemo Is Not Always Necessary
This is usually unnecessary:
const fullName = useMemo(() => {
return firstName + " " + lastName;
}, [firstName, lastName]);
The calculation is extremely cheap.
Just write:
const fullName = firstName + " " + lastName;
Use useMemo when:
- A calculation is genuinely expensive.
- A stable object or array reference helps a memoized child.
- Profiling shows a meaningful improvement.
useMemo is a performance optimization, not a semantic guarantee. Your application should remain correct even if React recalculates the value.
7. useCallback: Cache Function References
Functions are objects in JavaScript.
When a component renders, functions declared inside it are recreated.
Consider:
function Parent() {
const [count, setCount] = useState(0);
const handleClick = () => {
console.log("Clicked");
};
return (
<>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
<Child onClick={handleClick} />
</>
);
}
Even if the function's behavior hasn't changed, its reference is different after each render.
If Child is memoized, this new function can cause it to render again.
We can use useCallback:
import {
memo,
useCallback,
useState,
} from "react";
const Child = memo(function Child({ onClick }) {
console.log("Child rendered");
return (
<button onClick={onClick}>
Click Child
</button>
);
});
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log("Clicked");
}, []);
return (
<>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
<Child onClick={handleClick} />
</>
);
}
Now the function reference remains stable between renders.
When Does useCallback Help?
It is useful when:
- Passing callbacks to memoized children.
- A callback is a dependency of another Hook.
- Stable function identity prevents meaningful unnecessary work.
It is not necessary for every event handler.
Also remember that dependencies matter:
const handleAdd = useCallback(() => {
setCount(count + 1);
}, [count]);
Here, count must be included because the callback reads it.
An alternative is using a functional state update:
const handleAdd = useCallback(() => {
setCount((current) => current + 1);
}, []);
This callback no longer needs to capture count.
8. React Compiler and Automatic Memoization
Modern React development also includes the React Compiler.
In projects where it is correctly configured and enabled, the compiler can automatically optimize certain components, values, and functions.
This can reduce the need for manually adding memo, useMemo, and useCallback.
However:
- It is not automatically enabled in every React project.
- Your build setup must support it.
- You should follow the configuration requirements for your environment.
- It does not eliminate the need to understand performance fundamentals.
Manual memoization is still useful when working in environments without the compiler or when investigating specific performance problems.
The best approach is to understand what causes unnecessary work and use the optimization tools your project supports.
9. Use Component Composition to Reduce Re-rendering
Sometimes you can improve performance by changing the component structure rather than adding Hooks.
Imagine an expensive component that doesn't depend on a piece of frequently changing state.
function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
<ExpensiveComponent />
</div>
);
}
ExpensiveComponent renders whenever App renders.
One option is memoization. Another is component composition:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
function App() {
return (
<div>
<Counter />
<ExpensiveComponent />
</div>
);
}
Now the state update happens inside Counter, so it doesn't require App to render again.
You can also pass stable children into a component that owns state:
function Layout({ children }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>
Toggle
</button>
{open && <p>Menu is open</p>}
<main>{children}</main>
</div>
);
}
function App() {
return (
<Layout>
<ExpensiveComponent />
</Layout>
);
}
When Layout updates its own state, the children prop can remain the same reference because it was created by the parent.
This can help avoid unnecessary work in the child subtree.
Composition is often a clean architectural solution.
10. Avoid Unnecessary Effects and Derived State
Another source of unnecessary renders is storing values in state when they can be calculated from existing data.
Consider:
function ProductList({ products }) {
const [search, setSearch] = useState("");
const [filteredProducts, setFilteredProducts] = useState([]);
useEffect(() => {
const result = products.filter((product) =>
product.name
.toLowerCase()
.includes(search.toLowerCase())
);
setFilteredProducts(result);
}, [products, search]);
return (
// Render filteredProducts
<div />
);
}
This introduces an unnecessary update cycle:
-
searchchanges. - React renders the component.
- The Effect runs.
- The Effect updates
filteredProducts. - React renders again.
You don't need an Effect here.
Calculate the derived value during rendering:
function ProductList({ products }) {
const [search, setSearch] = useState("");
const filteredProducts = products.filter((product) =>
product.name
.toLowerCase()
.includes(search.toLowerCase())
);
return (
<div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search products..."
/>
{filteredProducts.map((product) => (
<p key={product.id}>
{product.name}
</p>
))}
</div>
);
}
If the calculation is expensive, add useMemo.
The general rule:
Don't use an Effect to synchronize values that can be calculated from existing props or state during rendering.
Also, logic that belongs to a user action should usually live in the event handler.
For example, if clicking a button should submit a form, perform that action in the click or submit handler rather than watching a state variable with an Effect.
Effects are primarily for synchronizing React with external systems, such as subscriptions, browser APIs, timers, and network connections.
11. Optimize Large Lists
Rendering large lists can be expensive.
Imagine displaying 10,000 products:
function ProductList({ products }) {
return (
<div>
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
/>
))}
</div>
);
}
React can handle lists, but rendering thousands of complex DOM elements can consume significant time and memory.
There are several strategies.
A. Use Stable Keys
Always use stable identifiers when rendering collections.
Good:
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
/>
))}
Avoid using array indexes when items can be inserted, deleted, reordered, or filtered:
{products.map((product, index) => (
<ProductCard
key={index}
product={product}
/>
))}
Indexes can cause React to associate the wrong component state with an item after the list changes.
B. Use Pagination
Instead of rendering every item, render a limited number at a time.
function ProductList({ products }) {
const [page, setPage] = useState(1);
const pageSize = 20;
const startIndex = (page - 1) * pageSize;
const visibleProducts = products.slice(
startIndex,
startIndex + pageSize
);
return (
<div>
{visibleProducts.map((product) => (
<p key={product.id}>
{product.name}
</p>
))}
<button
disabled={page === 1}
onClick={() => setPage((current) => current - 1)}
>
Previous
</button>
<button
disabled={startIndex + pageSize >= products.length}
onClick={() => setPage((current) => current + 1)}
>
Next
</button>
</div>
);
}
Pagination reduces the number of items rendered at once.
C. Use Virtualization
Virtualization, also called windowing, renders only the items currently visible in the viewport, plus a small buffer.
Instead of rendering 10,000 rows, the application might render only 20 or 30 at a time.
Popular libraries include:
react-window@tanstack/react-virtual
Virtualization is especially useful for:
- Large tables.
- Long product lists.
- Activity feeds.
- Logs.
- Data-heavy dashboards.
Remember that virtualization adds complexity. For smaller lists, ordinary rendering may be perfectly sufficient.
12. Code Splitting and Lazy Loading
A common performance problem is downloading the entire application before users need it.
Imagine an application with:
- Dashboard
- Reports
- Settings
- Admin panel
- Analytics
A user opening the dashboard may not need the analytics module immediately.
Code splitting allows us to load parts of the application when they are needed.
React.lazy
React provides lazy for loading components dynamically.
import { lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./Dashboard"));
const Reports = lazy(() => import("./Reports"));
function App() {
const [page, setPage] = useState("dashboard");
return (
<div>
<button onClick={() => setPage("dashboard")}>
Dashboard
</button>
<button onClick={() => setPage("reports")}>
Reports
</button>
<Suspense fallback={<p>Loading page...</p>}>
{page === "dashboard" ? (
<Dashboard />
) : (
<Reports />
)}
</Suspense>
</div>
);
}
The dynamically imported components can be loaded as separate chunks by a supporting build tool.
Suspense displays fallback UI while the lazy component is loading.
Route-Level Code Splitting
In larger applications, splitting code by route is often a good starting point.
For example:
const HomePage = lazy(() => import("./pages/HomePage"));
const ProfilePage = lazy(() => import("./pages/ProfilePage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
Then configure your routing system to load the appropriate page.
This can reduce the amount of JavaScript users need during the initial visit.
Don't forget to provide useful loading states. A blank screen is not a great user experience.
13. Optimize Images and Static Assets
JavaScript isn't the only source of performance problems.
Large images can significantly affect loading performance.
Consider these practices:
Use Appropriate Image Dimensions
Don't load a 4000-pixel image when the UI displays it at 300 pixels.
Serve an image close to the size actually needed.
Use Modern Formats
Formats such as WebP and AVIF can often reduce image size while maintaining good visual quality.
Lazy-Load Below-the-Fold Images
<img
src="/images/product.webp"
alt="Product"
loading="lazy"
width="400"
height="300"
/>
The browser can defer loading images that are not immediately needed.
However, don't blindly lazy-load critical images near the top of the page.
For an important hero image, eager loading or an appropriate fetch priority may be more suitable.
Use Responsive Images
<img
src="/images/product-800.webp"
srcSet="
/images/product-400.webp 400w,
/images/product-800.webp 800w,
/images/product-1200.webp 1200w
"
sizes="(max-width: 600px) 100vw, 50vw"
alt="Product"
width="800"
height="600"
/>
This allows the browser to select an appropriate image resource based on the display context.
Providing width and height also helps prevent layout shifts.
14. Reduce JavaScript Bundle Size
A large bundle can make an application slower to download, parse, and execute.
Some practical strategies:
Analyze Your Bundle
Use a bundle analyzer compatible with your build tool.
For example, Vite projects can use bundle visualization plugins to inspect which dependencies contribute most to the output.
Look for:
- Large libraries.
- Duplicate dependencies.
- Unused code.
- Modules that could be loaded on demand.
Avoid Unnecessarily Large Dependencies
Before adding a library, ask:
- Do I really need it?
- Can native JavaScript solve this problem?
- Is there a smaller alternative?
- Am I importing more than I use?
Use Tree Shaking
Tree shaking removes unused exports from production bundles when your build setup and dependencies support it.
Prefer clear imports:
import { format } from "date-fns";
But remember: actual tree-shaking behavior depends on the package's module format and build configuration.
Don't assume that every dependency will be optimized perfectly.
15. Keep Interfaces Responsive with useTransition
Sometimes an update is expensive, but the user should still be able to interact with the interface.
React provides useTransition to mark certain state updates as non-urgent.
Example:
import { useState, useTransition } from "react";
function SearchPage({ products }) {
const [input, setInput] = useState("");
const [search, setSearch] = useState("");
const [isPending, startTransition] = useTransition();
function handleChange(event) {
const value = event.target.value;
setInput(value);
startTransition(() => {
setSearch(value);
});
}
const filteredProducts = products.filter((product) =>
product.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div>
<input
value={input}
onChange={handleChange}
placeholder="Search products..."
/>
{isPending && <p>Updating results...</p>}
<div>
{filteredProducts.map((product) => (
<p key={product.id}>
{product.name}
</p>
))}
</div>
</div>
);
}
Here:
-
inputupdates urgently, keeping the input responsive. -
searchupdates as a transition. - React can prioritize urgent updates over transition work.
Important: useTransition does not make an expensive calculation inherently faster.
It helps React schedule updates so urgent interactions can remain responsive.
16. useDeferredValue: Defer Expensive UI Updates
useDeferredValue is another tool for keeping interfaces responsive.
It allows a part of the UI to use a deferred version of a value.
import {
memo,
useDeferredValue,
useState,
} from "react";
const ProductResults = memo(function ProductResults({
products,
search,
}) {
const filteredProducts = products.filter((product) =>
product.name
.toLowerCase()
.includes(search.toLowerCase())
);
return (
<div>
{filteredProducts.map((product) => (
<p key={product.id}>
{product.name}
</p>
))}
</div>
);
});
function SearchPage({ products }) {
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search);
return (
<div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search..."
/>
<ProductResults
products={products}
search={deferredSearch}
/>
</div>
);
}
The input receives the latest value immediately, while the results can update using the deferred value.
This can improve the perceived responsiveness of expensive UI.
A useful distinction:
-
useTransitionlets you mark state updates as non-urgent. -
useDeferredValuelets you defer updating a value used by part of the UI.
Neither replaces proper optimization of genuinely expensive calculations.
17. Don't Ignore Network Performance
A React application can render efficiently and still feel slow because it waits too long for data.
Consider these techniques:
Pagination on the Server
Don't fetch 50,000 records if the user only needs the first 20.
Request data in manageable pages.
Cache Server Data
Libraries such as TanStack Query help manage server state with features such as caching, background refetching, and request lifecycle management.
This can reduce unnecessary network requests and improve perceived performance.
Debounce Search Requests
If every keystroke sends a request, the application may generate unnecessary traffic.
Debouncing waits until the user pauses typing before triggering the request.
A simple example:
import { useEffect, useState } from "react";
function SearchBox() {
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
}, 300);
return () => clearTimeout(timer);
}, [query]);
useEffect(() => {
if (!debouncedQuery.trim()) return;
console.log("Fetch results for:", debouncedQuery);
}, [debouncedQuery]);
return (
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search..."
/>
);
}
In a real application, connect the second Effect to your API layer.
Also consider:
- Cancelling obsolete requests with
AbortController. - Handling race conditions.
- Avoiding duplicate requests.
- Showing loading and error states.
18. Practical Example: Optimizing a Searchable Product List
Let's combine several concepts.
Imagine a page with:
- A search field.
- A product list.
- A counter unrelated to the search.
- Product filtering.
Here is a simple implementation:
import { useState } from "react";
function ProductCard({ product }) {
console.log("Rendering:", product.name);
return (
<article>
<h3>{product.name}</h3>
<p>${product.price}</p>
</article>
);
}
function ProductList({ products, search }) {
const filteredProducts = products.filter((product) =>
product.name
.toLowerCase()
.includes(search.toLowerCase())
);
return (
<div>
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
/>
))}
</div>
);
}
export default function App() {
const [search, setSearch] = useState("");
const [count, setCount] = useState(0);
const products = [
{ id: 1, name: "Laptop", price: 1200 },
{ id: 2, name: "Phone", price: 800 },
{ id: 3, name: "Keyboard", price: 100 },
];
return (
<main>
<h1>Products</h1>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search products..."
/>
<button onClick={() => setCount((c) => c + 1)}>
Counter: {count}
</button>
<ProductList
products={products}
search={search}
/>
</main>
);
}
This example has a few opportunities for improvement:
-
productsis recreated wheneverApprenders. - Filtering runs whenever
ProductListrenders. - Product cards can render again even when their data hasn't changed.
- Counter updates can trigger work unrelated to the counter.
Here is a more optimized structure:
import {
memo,
useMemo,
useState,
} from "react";
const ProductCard = memo(function ProductCard({ product }) {
return (
<article>
<h3>{product.name}</h3>
<p>${product.price}</p>
</article>
);
});
function ProductSearch({ products }) {
const [search, setSearch] = useState("");
const filteredProducts = useMemo(() => {
const normalizedSearch = search.toLowerCase();
return products.filter((product) =>
product.name
.toLowerCase()
.includes(normalizedSearch)
);
}, [products, search]);
return (
<section>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search products..."
/>
<div>
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
/>
))}
</div>
</section>
);
}
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((c) => c + 1)}>
Counter: {count}
</button>
);
}
export default function App() {
const products = [
{ id: 1, name: "Laptop", price: 1200 },
{ id: 2, name: "Phone", price: 800 },
{ id: 3, name: "Keyboard", price: 100 },
];
return (
<main>
<h1>Products</h1>
<Counter />
<ProductSearch products={products} />
</main>
);
}
Now the counter's state is isolated, so updating it doesn't cause the product search component to render again.
The product list also memoizes its filtering calculation.
One important detail: in this example, the products array is created inside App. If App later gains state that changes, that array will receive a new reference. In a real application, keep static data outside the component, obtain it from a stable data source, or memoize it when necessary.
This example illustrates an important lesson:
Good component architecture is often the first performance optimization.
19. Common Performance Optimization Mistakes
Avoid these mistakes:
1. Using memoization everywhere
Memoization adds complexity and has its own costs.
Optimize components that actually need it.
2. Using useEffect for derived state
This can introduce unnecessary render cycles.
Calculate derived values during rendering instead.
3. Rendering thousands of DOM elements
Consider pagination or virtualization for genuinely large lists.
4. Ignoring bundle size
A fast component won't help much if users must download an unnecessarily large application.
5. Optimizing without measuring
You might spend hours improving code that isn't responsible for the slowdown.
6. Using unstable keys
Incorrect keys can cause bugs and unnecessary component work.
7. Treating useTransition as a performance magic button
Transitions improve scheduling and responsiveness. They don't automatically make calculations cheaper.
20. Performance Optimization Checklist
Before shipping a React application, review this checklist:
- [ ] Have I measured performance before optimizing?
- [ ] Have I checked expensive components with React Profiler?
- [ ] Is state located close to where it is needed?
- [ ] Am I avoiding unnecessary Effects?
- [ ] Are expensive calculations cached when measurement justifies it?
- [ ] Are memoized components receiving stable props?
- [ ] Am I using stable keys for dynamic lists?
- [ ] Should large lists use pagination or virtualization?
- [ ] Have I implemented code splitting where useful?
- [ ] Are images properly sized and optimized?
- [ ] Have I checked the production bundle size?
- [ ] Am I avoiding unnecessary network requests?
- [ ] Does the interface remain responsive during expensive updates?
Conclusion
React performance optimization is not about adding as many Hooks as possible.
It's about understanding where time is being spent and choosing the right solution.
The most important principles are:
- Measure before optimizing.
- Understand why components render.
- Keep state close to where it is needed.
- Use
memo,useMemo, anduseCallbackwhen they provide measurable value. - Avoid unnecessary Effects and duplicate state.
- Optimize large lists with pagination or virtualization.
- Use code splitting and lazy loading.
- Optimize images, bundles, and network requests.
- Keep urgent interactions responsive with transitions when appropriate.
And remember:
The fastest code is often the code you don't need to execute.
Start with clean architecture, measure real problems, and optimize only where it matters.
What's Next?
In Part 7 — React Internals: Virtual DOM, Fiber & Reconciliation, we'll go deeper into how React works internally.
We'll explore:
- What the Virtual DOM actually is.
- How reconciliation works.
- What React Fiber does.
- How React decides which updates to commit.
- Why keys matter internally.
- How React schedules rendering work.
Understanding these internals will help you become a much stronger React developer.
See you in Part 7! 🚀
Top comments (0)