How to Optimize React App Performance
React is fast by default, but “fast by default” does not mean “fast no matter what.” As your app grows, little inefficiencies start to pile up. A component re-renders when it does not need to. A large list gets rendered all at once. A function is recreated on every render. An API call runs too often. Suddenly the app feels heavier, even though the code still looks “fine.”
That is the tricky part about React performance: the problems usually do not appear all at once. They creep in.
The good news is that React gives you a lot of tools to keep your app responsive, predictable, and pleasant to use. You do not need to rewrite everything or become obsessed with micro-optimizations. In most cases, performance comes from making a few smart decisions in the right places.
This article will walk through practical ways to optimize a React app, with clear examples and a human approach. We will look at real performance bottlenecks, the most useful React tools, when to use them, when not to use them, and how to think about performance without turning your code into a puzzle.
Start by Measuring, Not Guessing
A lot of performance work goes wrong at the very beginning: people try to optimize code before they know what is actually slow.
That is understandable. It is tempting to assume the biggest file or the most complex component is the problem. But performance issues are often more subtle. A small component might re-render too often. A list might be rendering way too many items. A single effect might be triggering unnecessary network requests.
Before changing code, ask:
What feels slow?
Is the problem on initial load, during interaction, or on navigation?
Is the issue CPU, rendering, network, or JavaScript bundle size?
Does the slowdown happen everywhere or only on specific pages?
React provides a Profiler in React DevTools, and the browser gives you performance tools as well. Those are the first places to look.
What you should measure
You usually want to identify one of these:
slow initial page load
slow component rendering
expensive re-renders
long list rendering
laggy typing or scrolling
delayed API-driven updates
large bundle size
too many side effects
If you do not know where the slowdown is, fix the guessing first. Otherwise, you may spend time optimizing something that was never the problem.
Understand What Causes Re-renders
A lot of React performance problems come from one simple fact: when state changes, React re-renders components. That is normal. It is not a bug. But unnecessary re-renders can become expensive when the tree is large or the computation inside a component is heavy.
A component re-renders when:
its own state changes
its props change
its parent re-renders and passes new props
its context value changes
some external store it uses updates
A re-render is not automatically bad. React is designed to do that. The real issue is when re-renders happen more often than needed or when the render work is too expensive.
Example of unnecessary re-renders
import { useState } from "react";
function Child({ onClick }) {
console.log("Child rendered");
return <button onClick={onClick}>Click me</button>;
}
export default function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<Child onClick={() => setCount(count + 1)} />
</div>
);
}
In this example, the child re-renders every time the parent renders, and the onClick function is recreated each time. That may be perfectly fine in a tiny component. But in larger apps, function and object identity can start affecting child performance.
The bigger lesson is this: do not assume “React re-rendered” means “React is slow.” Sometimes the render is cheap. Sometimes it is not. Measure first.
Keep Components Small and Focused
One of the easiest performance wins is also one of the cleanest design habits: keep components focused on one responsibility.
A massive component that handles layout, state, network requests, filtering, calculations, and rendering all at once is harder to optimize. A smaller component tree is easier to reason about, easier to memoize selectively, and easier to split later.
Why smaller components help
fewer unnecessary updates across unrelated UI
easier extraction of expensive parts
simpler memoization
less complex props
easier debugging
Example: split a heavy component
Instead of this:
function Dashboard() {
// lots of state
// lots of logic
// big render tree
return <div>{/* everything in one place */}</div>;
}
Prefer this:
function Dashboard() {
return (
<div>
<StatsPanel />
<ActivityFeed />
<Notifications />
<QuickActions />
</div>
);
}
Then optimize only the parts that need it. This makes performance work much more surgical.
Use React.memo for Pure Components
React.memo is useful when a component renders the same output for the same props. It tells React to skip rendering that component if the props have not changed.
Example
import React, { useState } from "react";
const UserCard = React.memo(function UserCard({ name, role }) {
console.log("UserCard rendered");
return (
<div>
<h3>{name}</h3>
<p>{role}</p>
</div>
);
});
export default function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(prev => prev + 1)}>
Counter: {count}
</button>
<UserCard name="Amina" role="Developer" />
</div>
);
}
If UserCard always receives the same props, memoization can prevent unnecessary re-renders when the parent updates for unrelated reasons.
When to use React.memo
Use it when:
a child component is rendered often
its props are usually stable
rendering the component is relatively expensive
you have evidence that re-renders are causing issues
When not to use it
Do not add React.memo everywhere automatically. It is not free. If a component is cheap to render, memoization may not help much. In some cases, it adds complexity without a real gain.
A good rule is: memoize components that are both expensive and frequently re-rendered.
Stabilize Functions with useCallback
In React, functions are recreated on every render. That is normal JavaScript behavior. But when you pass a function down to a memoized child component, a new function reference can trigger a re-render even if nothing else changed.
useCallback helps keep the same function reference across renders until dependencies change.
Example
import React, { useCallback, useState } from "react";
const Button = React.memo(function Button({ onAdd }) {
console.log("Button rendered");
return <button onClick={onAdd}>Add</button>;
});
export default function CounterApp() {
const [count, setCount] = useState(0);
const [name, setName] = useState("");
const handleAdd = useCallback(() => {
setCount(prev => prev + 1);
}, []);
return (
<div>
<p>Count: {count}</p>
<input value={name} onChange={e => setName(e.target.value)} />
<Button onAdd={handleAdd} />
</div>
);
}
Typing in the input changes name, which re-renders the parent. Because handleAdd is stable, the memoized Button has a better chance of avoiding unnecessary re-renders.
Important note
Do not use useCallback just because you saw it in a performance article. It is helpful when function identity matters, but it does not magically make everything faster. In some cases, it makes code more complicated than necessary.
Use it where it actually protects a memoized child or stable dependency.
Cache Expensive Values with useMemo
useMemo is for expensive calculations that do not need to run on every render. It caches the computed value until dependencies change.
Example: filtering a large list
import { useMemo, useState } from "react";
const products = [
"Laptop",
"Keyboard",
"Mouse",
"Monitor",
"Phone",
"Tablet",
"Headphones",
"Charger",
"Camera",
"Speaker"
];
export default function ProductSearch() {
const [query, setQuery] = useState("");
const [theme, setTheme] = useState("light");
const filteredProducts = useMemo(() => {
return products.filter(product =>
product.toLowerCase().includes(query.toLowerCase())
);
}, [query]);
return (
<div>
<button onClick={() => setTheme(prev => (prev === "light" ? "dark" : "light"))}>
Toggle Theme
</button>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>
{filteredProducts.map(product => (
<li key={product}>{product}</li>
))}
</ul>
</div>
);
}
If changing theme causes the component to re-render, useMemo prevents the filter calculation from running again unless query changes.
When useMemo helps
large filtered lists
expensive sorting
complex derived data
repeated calculations that are slow
values used by memoized children
When not to use it
Do not wrap every computation in useMemo. Many calculations are so cheap that memoization is not worth the extra code.
Use it when the cost of recalculation is noticeable or when derived data is being passed to memoized children.
Avoid Recreating Objects and Arrays Unnecessarily
Another common performance issue comes from passing new object and array references as props on every render.
Example of the problem
function Parent() {
const options = { theme: "dark", compact: true };
return <Child options={options} />;
}
Every render creates a new options object. Even if the values are the same, the reference is new.
A memoized version
import { useMemo } from "react";
function Parent() {
const options = useMemo(() => ({ theme: "dark", compact: true }), []);
return <Child options={options} />;
}
This matters most when:
the prop is passed to a memoized child
the object is part of a dependency array
the value is expensive to create
The same idea applies to arrays.
import { useMemo } from "react";
function Parent() {
const items = useMemo(() => ["A", "B", "C"], []);
return <Child items={items} />;
}
Again, do this with purpose. Stable references matter when they unlock a real optimization path.
Render Less UI at a Time
Sometimes the best performance improvement is not “make React faster.” It is “render less stuff.”
This can mean:
showing fewer items
splitting pages into sections
lazy loading content
virtualizing long lists
collapsing heavy panels until needed
Example: conditional rendering
function App() {
const [showDetails, setShowDetails] = useState(false);
return (
<div>
<button onClick={() => setShowDetails(prev => !prev)}>
Toggle Details
</button>
{showDetails && <HeavyDetailsPanel />}
</div>
);
}
If a section is expensive and not always visible, do not render it until it is actually needed.
Tabs and accordions
Tabbed interfaces are a great place to reduce render cost. Only render the active tab’s content, or keep inactive tabs lightweight.
Virtualize Long Lists
Large lists are one of the most common performance pain points in React apps. Rendering 5,000 or 50,000 DOM nodes at once can become slow very quickly.
The solution is often virtualization: render only the items visible in the viewport, plus a small buffer.
Instead of rendering the entire list, virtualization gives the user the illusion of a full list while only mounting a small number of items.
Simple example concept
A normal long list:
function BigList({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.label}</li>
))}
</ul>
);
}
This is fine for small lists, but not for huge ones.
For large datasets, a virtualization library is usually the better choice. The exact implementation depends on your project, but the idea stays the same: do not render what the user cannot see.
Why virtualization is powerful
fewer DOM nodes
faster initial render
smoother scrolling
less memory usage
If your app has big tables, message feeds, logs, search results, or admin dashboards with huge lists, virtualization is often one of the highest-value optimizations you can make.
Use Pagination or Infinite Scroll Carefully
If you do not need to show every item at once, do not. Pagination and infinite scroll can keep the interface responsive.
Pagination example
import { useState } from "react";
function PaginatedList({ items }) {
const [page, setPage] = useState(1);
const perPage = 10;
const start = (page - 1) * perPage;
const visibleItems = items.slice(start, start + perPage);
return (
<div>
<ul>
{visibleItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
<button onClick={() => setPage(prev => Math.max(prev - 1, 1))}>Prev</button>
<button onClick={() => setPage(prev => prev + 1)}>Next</button>
</div>
);
}
This reduces how many items render at once and makes the UI more manageable.
Infinite scroll can also help, but it should be used carefully. It is great for content feeds, but it can make navigation and memory management trickier if not implemented well.
Defer Non-Urgent Updates with useDeferredValue
Sometimes you want the UI to stay responsive while React handles a heavy update in the background. That is where useDeferredValue can help.
It lets the app keep typing, clicking, or interacting smoothly while a slower part of the UI catches up.
Example
import { useDeferredValue, useMemo, useState } from "react";
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => {
const items = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`);
return items.filter(item =>
item.toLowerCase().includes(deferredQuery.toLowerCase())
);
}, [deferredQuery]);
return (
<ul>
{results.slice(0, 20).map(item => (
<li key={item}>{item}</li>
))}
</ul>
);
}
export default function App() {
const [query, setQuery] = useState("");
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<SearchResults query={query} />
</div>
);
}
This can improve perceived performance, especially in search-heavy interfaces.
Use useTransition for Smoother UI
useTransition helps you mark some updates as less urgent. That can make typing, filtering, and navigation feel smoother.
Example
import { useState, useTransition } from "react";
function SearchPage({ items }) {
const [query, setQuery] = useState("");
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
setQuery(value);
startTransition(() => {
const next = items.filter(item =>
item.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(next);
});
}
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <p>Updating results...</p>}
<ul>
{filteredItems.map(item => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
This is especially useful when an update causes expensive rendering and you want the interface to remain interactive.
Split Code with Lazy Loading
One of the biggest performance wins is reducing the JavaScript the user must download up front.
React supports lazy loading with React.lazy and Suspense, which lets you load components only when they are needed.
Example
import React, { Suspense, lazy, useState } from "react";
const HeavyChart = lazy(() => import("./HeavyChart"));
export default function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Load Chart</button>
<Suspense fallback={<p>Loading chart...</p>}>
{showChart && <HeavyChart />}
</Suspense>
</div>
);
}
This helps because the chart component does not need to be included in the initial render path. It only loads when the user requests it.
When lazy loading helps most
routes that are not needed immediately
heavy charts
modals
admin-only features
complex editors
rarely used screens
Code Splitting by Route
Route-based code splitting is one of the most practical ways to improve startup speed.
If users do not need every page right away, do not send every page’s code right away.
A simple routing structure can be combined with lazy-loaded route components so each page loads on demand.
This is often a major improvement for large apps with many routes.
Reduce Unnecessary State
One subtle source of performance problems is storing too much in state. If some value can be derived from existing state or props, you often do not need to store it separately.
Example of redundant state
function Profile({ firstName, lastName }) {
const [fullName, setFullName] = useState(`${firstName} ${lastName}`);
return <p>{fullName}</p>;
}
This can become stale if firstName or lastName changes.
Better version
function Profile({ firstName, lastName }) {
const fullName = `${firstName} ${lastName}`;
return <p>{fullName}</p>;
}
Storing less state means fewer updates and less complexity.
Rule of thumb
Store only what you need to remember. If the value can be derived during render, derive it during render.
That keeps your app simpler and often faster.
Avoid Deep Prop Drilling When It Leads to Waste
Prop drilling is not always a performance problem by itself, but it can lead to messy updates and unnecessary re-renders if you keep threading values through many layers.
If many distant components need the same value, consider:
context
a dedicated state store
local state lifted to the nearest common parent
composition patterns
Example with context
import { createContext, useContext, useState } from "react";
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState({ name: "Hassan" });
return (
<AuthContext.Provider value={{ user, setUser }}>
{children}
</AuthContext.Provider>
);
}
function UserProfile() {
const { user } = useContext(AuthContext);
return <p>Hello, {user.name}</p>;
}
Context is powerful, but it should be used thoughtfully. If one context value changes too often, it can cause broad re-renders across consumers. In those cases, separating contexts can help.
Be Careful with Context Updates
Context is convenient, but it can also become a performance hotspot if one large context object changes frequently.
Problem example
<AuthContext.Provider value={{ user, theme, notifications, settings }}>
{children}
</AuthContext.Provider>
If any field changes, all consumers may re-render.
Better approach
Split context by concern:
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<SettingsContext.Provider value={settings}>
{children}
</SettingsContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
Or use more selective state management patterns when context becomes too broad.
This is one of those areas where “simple” is not always “small.” A few focused contexts are often better than one giant shared object.
Optimize Images and Static Assets
React performance is not only about React code. Sometimes the slow part is the image payload, fonts, or static assets.
Common fixes
compress images
use modern image formats
resize images to the actual display size
lazy load below-the-fold images
avoid huge background images
preload only critical assets
limit font variants
If a page feels slow to load, it may not be JavaScript at all. It might be image weight or too many network requests.
Example: lazy loading images
function GalleryImage({ src, alt }) {
return <img src={src} alt={alt} loading="lazy" />;
}
This small change can improve page load behavior when there are many images below the fold.
Reduce Network Waterfalls
Sometimes a React app feels slow because it is waiting too long for data.
A network waterfall happens when one request depends on another, and each step waits for the previous one to finish. That can make pages feel sluggish.
Ways to improve
fetch in parallel when possible
avoid unnecessary sequential requests
cache results
prefetch likely next pages
avoid refetching data that has not changed
group API calls where it makes sense
Example: parallel requests
import { useEffect, useState } from "react";
function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
async function loadData() {
const [usersRes, postsRes] = await Promise.all([
fetch("/api/users"),
fetch("/api/posts")
]);
const [users, posts] = await Promise.all([
usersRes.json(),
postsRes.json()
]);
setData({ users, posts });
}
loadData();
}, []);
return <div>{data ? "Loaded" : "Loading..."}</div>;
}
Promise.all lets both requests happen together rather than one after the other.
Avoid Fetching in Too Many Places
A common performance smell is when multiple components independently fetch the same data. That can create duplicate network requests and duplicate loading states.
Better options include:
lifting fetching to a parent
using shared data hooks
caching
server-side data hydration
centralized query management
Example of duplicated fetching
function Header() {
// fetch user data here
}
function Sidebar() {
// fetch the same user data again
}
This is wasteful if both components need the same information.
Instead, fetch once and share the result.
Handle Expensive Calculations Outside the Render Path
Heavy work during render is one of the fastest ways to make an interface feel sluggish.
Examples include:
sorting large arrays
deep cloning
parsing huge JSON structures
expensive date formatting
repeated data transformation
Better approach
Move work:
to memoized values
to the server
to background processing
to event handlers when appropriate
Example: heavy sort with memoization
import { useMemo, useState } from "react";
function SortedList({ items }) {
const [reverse, setReverse] = useState(false);
const sortedItems = useMemo(() => {
return [...items].sort((a, b) => {
const result = a.name.localeCompare(b.name);
return reverse ? -result : result;
});
}, [items, reverse]);
return (
<div>
<button onClick={() => setReverse(prev => !prev)}>
Toggle Sort
</button>
<ul>
{sortedItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
Notice the copy with [...items] before sorting. That avoids mutating the original array.
Debounce User Input
When a user types quickly, you may not want to run a filter, request, or expensive computation on every single keystroke.
Debouncing waits for a pause before triggering the action.
Example of a custom debounce hook
import { useEffect, useState } from "react";
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
Using it
import { useMemo, useState } from "react";
function SearchBox({ items }) {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
const filtered = useMemo(() => {
return items.filter(item =>
item.toLowerCase().includes(debouncedQuery.toLowerCase())
);
}, [items, debouncedQuery]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>
{filtered.map(item => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
Debouncing is especially useful for search bars, auto-complete inputs, and live filters.
Watch Out for Uncontrolled Effects
Effects can quietly hurt performance when they run too often or do too much work.
Common issues
missing dependency arrays
effects tied to rapidly changing values
setting state inside effects unnecessarily
doing work in effects that should happen during render or in handlers
causing feedback loops
Example of an effect that runs too often
useEffect(() => {
fetchData();
}, [{ query }]);
That dependency is wrong because the object is recreated each render. It should be a stable value, not a new object reference every time.
Correct version:
useEffect(() => {
fetchData();
}, [query]);
Always check whether an effect really needs to exist. Some logic can be moved elsewhere, and removing an unnecessary effect is often a performance improvement as well as a readability improvement.
Use Stable Keys in Lists
Keys are not just a React warning issue. They affect how React matches old and new elements during updates.
Bad key usage
{items.map((item, index) => (
<li key={index}>{item.name}</li>
))}
Using the array index as a key can cause unnecessary remounts or incorrect UI behavior when items are reordered, inserted, or removed.
Better key usage
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
Stable keys help React update lists efficiently and correctly.
This is important for performance and for preserving component state inside list items.
Keep State Local When Possible
It can be tempting to lift state too high in the tree just to “centralize” everything. But high-level state updates can cause wide re-renders across many components.
Better approach
Keep state as close as possible to the component that uses it.
For example:
form field state in the form
hover state in the item
modal open state near the modal trigger
local toggle state in the section that needs it
When state is too high, every change can ripple farther than necessary.
Use Browser Caching and HTTP Caching
Even if your React code is optimized, users still have to download assets and API responses.
A strong caching strategy helps a lot:
cache static assets with long lifetimes
use ETags or cache headers for API responses
avoid re-downloading unchanged bundles
prefetch likely next resources
This is technically beyond React itself, but it makes a huge difference in perceived performance.
A fast React app is not just about rendering fast. It is also about getting the right files and data to the browser efficiently.
Prefer Production Builds
React development mode includes extra checks and warnings that can make the app feel slower than it really is.
Always judge performance using a production build when possible.
Development mode is useful for debugging. Production mode is what your users actually experience.
If something feels slow in development, confirm whether the issue still exists in production. That saves time and avoids false alarms.
Avoid Overly Deep Component Trees
Very deep trees are not automatically bad, but they can make updates harder to reason about and debugging more painful.
Sometimes a deep tree means:
too much abstraction
too many wrapper components
unnecessary prop passing
too much context usage
harder profiling
A well-structured tree is usually cleaner than a deeply nested one.
That does not mean flatten everything. It means being intentional. Good component boundaries help performance and maintainability at the same time.
Memoize Only Where It Pays Off
This is worth repeating because it is one of the most common mistakes in React performance work.
Memoization is useful, but it is not magic.
Ask:
Is this component expensive to render?
Does it re-render often?
Are its props stable enough?
Is the child actually causing a bottleneck?
Can I prove this optimization helps?
If the answer is no, skip it. A simpler component is often better than a more “optimized” one that is harder to maintain.
Performance work should make the app smoother, not the code stranger.
Profile Before and After Every Change
A nice habit in performance work is to compare before and after.
After changing something:
verify the UI still behaves correctly
test on a realistic dataset
use the Profiler again
check whether the change actually reduced work
make sure you did not trade one bottleneck for another
Sometimes an optimization helps one screen but hurts another. You want evidence, not assumptions.
A Practical Performance Checklist
When a React app feels slow, this checklist often helps:
Are there unnecessary re-renders?
Is a large list rendering all at once?
Are expensive calculations happening during render?
Are functions and objects being recreated too often?
Is state stored higher than necessary?
Are effects running too often?
Are list keys stable?
Can part of the UI be lazy loaded?
Is any data fetched more than once?
Is the bundle too large?
Can virtualization or pagination reduce work?
Is the problem actually network latency rather than React rendering?
This is usually enough to find the real issue without chasing shadows.
A Small Example: Optimized Product Dashboard
Here is a more complete example that combines several ideas.
import React, { useCallback, useDeferredValue, useMemo, useState } from "react";
const initialProducts = [
{ id: 1, name: "Laptop", price: 1200 },
{ id: 2, name: "Keyboard", price: 80 },
{ id: 3, name: "Mouse", price: 40 },
{ id: 4, name: "Monitor", price: 300 },
{ id: 5, name: "Headphones", price: 150 }
];
const ProductRow = React.memo(function ProductRow({ product, onSelect }) {
console.log("Rendering", product.name);
return (
<li>
<button onClick={() => onSelect(product.id)}>
{product.name} - ${product.price}
</button>
</li>
);
});
export default function ProductDashboard() {
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState(null);
const deferredQuery = useDeferredValue(query);
const filteredProducts = useMemo(() => {
return initialProducts.filter(product =>
product.name.toLowerCase().includes(deferredQuery.toLowerCase())
);
}, [deferredQuery]);
const handleSelect = useCallback((id) => {
setSelectedId(id);
}, []);
const selectedProduct = useMemo(() => {
return initialProducts.find(p => p.id === selectedId);
}, [selectedId]);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search products"
/>
{selectedProduct && <p>Selected: {selectedProduct.name}</p>}
<ul>
{filteredProducts.map(product => (
<ProductRow
key={product.id}
product={product}
onSelect={handleSelect}
/>
))}
</ul>
</div>
);
}
This example uses:
useStatefor input and selectionuseDeferredValuefor smoother filteringuseMemofor filtered data and selected item lookupuseCallbackfor stable event handlingReact.memofor item rows
The result is not just “optimized.” It is also organized in a way that makes the performance decisions easy to understand.
Think About User Experience, Not Just Metrics
Performance is not only about milliseconds. It is also about how the app feels.
A user does not care whether a component technically rendered in 12 ms or 18 ms. They care whether:
typing feels instant
buttons respond immediately
scrolling is smooth
navigation feels snappy
content appears at the right time
loading states make sense
That is why perceived performance matters so much. A thoughtful loading skeleton, deferred search, or lazy-loaded section can make an app feel much better even before every underlying optimization is perfect.
A Gentle Truth About Performance
Not every app needs deep optimization.
A small or medium React app may already feel excellent with only a few sensible practices:
keep components simple
avoid unnecessary state
render less when possible
use stable keys
fetch data wisely
lazy load heavy parts
profile only when needed
That is often enough.
The goal is not to turn every component into a performance experiment. The goal is to build an app that stays pleasant as it grows.
Final Thoughts
Optimizing a React app is partly about technical tools and partly about good judgment.
The tools are familiar:
React.memouseCallbackuseMemouseDeferredValueuseTransitionReact.lazyvirtualization
pagination
caching
stable keys
smaller component boundaries
But the real skill is knowing when to use them.
A fast React app usually comes from a series of smart, readable choices rather than one dramatic trick. Keep the UI lean. Measure before changing things. Memoize with intent. Split heavy work away from the main render path. Load less when possible. Keep state local. Use effects carefully. Render only what the user needs.
That approach will take you a long way.
And perhaps the nicest part is this: performance work does not have to make your code ugly. Done well, it makes your app smoother and your code clearer at the same time.