How to Optimize React App Performance

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:

  • useState for input and selection

  • useDeferredValue for smoother filtering

  • useMemo for filtered data and selected item lookup

  • useCallback for stable event handling

  • React.memo for 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.memo

  • useCallback

  • useMemo

  • useDeferredValue

  • useTransition

  • React.lazy

  • virtualization

  • 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.