DEV Community

Cover image for React Mastery Series — Part 6: React Performance Optimization
Hamed Farazi
Hamed Farazi

Posted on

React Mastery Series — Part 6: React Performance Optimization

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:

  1. Open your application.
  2. Open React Developer Tools.
  3. Select the Profiler tab.
  4. Start recording.
  5. Interact with the application.
  6. Stop recording.
  7. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

When the button is clicked:

  1. The state update is scheduled.
  2. React calls the component function again.
  3. React calculates the next UI.
  4. React compares the result with the previous render.
  5. 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:

  1. The component's own state changes.
  2. Its parent renders again.
  3. A Context value it consumes changes.
  4. 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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

There is a problem.

Every time App renders, it creates a new object:

const user = {
  name: "Alex",
  email: "alex@example.com",
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

The calculation is extremely cheap.

Just write:

const fullName = firstName + " " + lastName;
Enter fullscreen mode Exit fullscreen mode

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} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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);
}, []);
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 />
  );
}
Enter fullscreen mode Exit fullscreen mode

This introduces an unnecessary update cycle:

  1. search changes.
  2. React renders the component.
  3. The Effect runs.
  4. The Effect updates filteredProducts.
  5. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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}
  />
))}
Enter fullscreen mode Exit fullscreen mode

Avoid using array indexes when items can be inserted, deleted, reordered, or filtered:

{products.map((product, index) => (
  <ProductCard
    key={index}
    product={product}
  />
))}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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"
/>
Enter fullscreen mode Exit fullscreen mode

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"
/>
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here:

  • input updates urgently, keeping the input responsive.
  • search updates 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

  • useTransition lets you mark state updates as non-urgent.
  • useDeferredValue lets 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..."
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

This example has a few opportunities for improvement:

  1. products is recreated whenever App renders.
  2. Filtering runs whenever ProductList renders.
  3. Product cards can render again even when their data hasn't changed.
  4. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Measure before optimizing.
  2. Understand why components render.
  3. Keep state close to where it is needed.
  4. Use memo, useMemo, and useCallback when they provide measurable value.
  5. Avoid unnecessary Effects and duplicate state.
  6. Optimize large lists with pagination or virtualization.
  7. Use code splitting and lazy loading.
  8. Optimize images, bundles, and network requests.
  9. 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)