正在加载,请稍候…

React Performance Optimization: A Practical Checklist

Speed up your React app with proven techniques: React.memo, useMemo, useCallback, code splitting, virtualization, and avoiding common re-render traps.

Why React Apps Slow Down

React is fast by default, but several patterns cause unnecessary work:

  1. Unnecessary re-renders — components re-rendering when their output wouldn't change
  2. Expensive computations on every render — recalculating derived data unnecessarily
  3. Large bundle sizes — loading code the user doesn't need yet
  4. Long render lists — rendering thousands of DOM nodes at once
  5. Layout thrashing — JS reads/writes that force the browser to recalculate layout repeatedly

This guide is a practical checklist, not theory.

Step 1: Measure Before Optimizing

Never optimize blindly. Use these tools first:

// React DevTools Profiler
// 1. Install React DevTools browser extension
// 2. Open DevTools → Profiler tab
// 3. Record a user interaction
// 4. Look for components that render often or take long

// Detect unnecessary re-renders in development
// Add this to component to see when it renders:
const MyComponent = ({ value }) => {
  console.log('MyComponent rendered:', value);
  return <div>{value}</div>;
};

// Or use react-why-did-you-render
import whyDidYouRender from '@welldone-software/why-did-you-render';
whyDidYouRender(React, { trackAllPureComponents: true });

Step 2: Fix Unnecessary Re-renders

React.memo — Memoize Components

// Without memo: re-renders whenever parent re-renders
const UserCard = ({ user }) => (
  <div>{user.name} - {user.email}</div>
);

// With memo: only re-renders when user prop changes (shallow comparison)
const UserCard = React.memo(({ user }) => (
  <div>{user.name} - {user.email}</div>
));

// Custom comparison (when shallow check isn't enough)
const UserCard = React.memo(
  ({ user }) => <div>{user.name}</div>,
  (prevProps, nextProps) => prevProps.user.id === nextProps.user.id
);

When to use: Components that render often but receive the same props frequently — list items, chart nodes, table rows.

When NOT to use: Simple components that render rarely. The memoization overhead can cost more than re-rendering.

Stable Callback References

// ❌ New function on every render → memo'd children always re-render
function ParentList() {
  const [items, setItems] = useState([]);

  return items.map(item => (
    <MemoizedItem
      key={item.id}
      item={item}
      onDelete={() => setItems(prev => prev.filter(i => i.id !== item.id))} // new fn each render
    />
  ));
}

// ✅ Stable callback with useCallback
function ParentList() {
  const [items, setItems] = useState([]);

  const handleDelete = useCallback((id) => {
    setItems(prev => prev.filter(i => i.id !== id));
  }, []); // stable reference

  return items.map(item => (
    <MemoizedItem key={item.id} item={item} onDelete={handleDelete} />
  ));
}

Stable Object References

// ❌ New object on every render
function Chart({ data }) {
  const options = { color: 'blue', animated: true }; // new reference each render

  return <MemoizedChart data={data} options={options} />; // always re-renders
}

// ✅ Memoize the options object
function Chart({ data }) {
  const options = useMemo(
    () => ({ color: 'blue', animated: true }),
    [] // never changes — could also be module-level constant
  );

  return <MemoizedChart data={data} options={options} />;
}

Step 3: Memoize Expensive Computations

// ❌ Filters 10,000 items on every render
function ProductList({ products, search, category, maxPrice }) {
  const filtered = products
    .filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
    .filter(p => category ? p.category === category : true)
    .filter(p => p.price <= maxPrice)
    .sort((a, b) => a.price - b.price);

  return filtered.map(p => <ProductCard key={p.id} product={p} />);
}

// ✅ Only recomputes when inputs change
function ProductList({ products, search, category, maxPrice }) {
  const filtered = useMemo(
    () => products
      .filter(p => p.name.toLowerCase().includes(search.toLowerCase()))
      .filter(p => category ? p.category === category : true)
      .filter(p => p.price <= maxPrice)
      .sort((a, b) => a.price - b.price),
    [products, search, category, maxPrice]
  );

  return filtered.map(p => <ProductCard key={p.id} product={p} />);
}

Step 4: State Architecture

Colocate State

Move state as close to where it's used as possible. Global state causes widespread re-renders.

// ❌ Tooltip open state in global store → every component re-renders on hover
const { isTooltipOpen, setTooltipOpen } = useGlobalStore();

// ✅ Tooltip state local to the tooltip component
function TooltipWrapper({ children, content }) {
  const [isOpen, setIsOpen] = useState(false); // only this component re-renders
  return (
    <div onMouseEnter={() => setIsOpen(true)} onMouseLeave={() => setIsOpen(false)}>
      {children}
      {isOpen && <Tooltip>{content}</Tooltip>}
    </div>
  );
}

Split Context

// ❌ One context for everything — any update re-renders all consumers
const AppContext = createContext({ user, theme, cart, notifications });

// ✅ Split into separate contexts
const UserContext = createContext(user);
const ThemeContext = createContext(theme);
const CartContext = createContext(cart);

// Components only subscribe to what they need
function PriceTag() {
  const cart = useContext(CartContext); // only re-renders on cart changes
  return <span>{cart.total}</span>;
}

Step 5: List Virtualization

Rendering 1,000+ rows creates 1,000+ DOM nodes. Virtualization only renders what's visible.

import { FixedSizeList as List } from 'react-window';

function VirtualizedList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style} className="list-item">
      {items[index].name}
    </div>
  );

  return (
    <List
      height={600}           // visible container height
      itemCount={items.length}
      itemSize={50}          // each item height
      width="100%"
    >
      {Row}
    </List>
  );
}

// For variable-height items
import { VariableSizeList } from 'react-window';

Use react-window for simple lists, react-virtual (TanStack Virtual) for more complex cases including grids and masonry layouts.

Step 6: Code Splitting

// ❌ Everything in one bundle
import HeavyEditor from './HeavyEditor';
import Chart from './Chart';

// ✅ Dynamic imports — load only when needed
import { lazy, Suspense } from 'react';

const HeavyEditor = lazy(() => import('./HeavyEditor'));
const Chart = lazy(() => import('./Chart'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/editor" element={<HeavyEditor />} />
        <Route path="/analytics" element={<Chart />} />
      </Routes>
    </Suspense>
  );
}

Prefetch on hover

// Preload the chunk before the user clicks
function NavLink({ to, children }) {
  const prefetch = () => import('./HeavyPage'); // triggers chunk download

  return (
    <Link to={to} onMouseEnter={prefetch} onFocus={prefetch}>
      {children}
    </Link>
  );
}

Step 7: Image and Asset Optimization

// Lazy load images below the fold
function ProductImage({ src, alt }) {
  return (
    <img
      src={src}
      alt={alt}
      loading="lazy"          // native lazy loading
      decoding="async"        // don't block rendering while decoding
      width={300}
      height={200}            // prevents layout shift
    />
  );
}

// For Next.js — use next/image
import Image from 'next/image';
<Image src={src} alt={alt} width={300} height={200} priority={false} />

Step 8: React 18 Concurrent Features

import { useTransition, useDeferredValue } from 'react';

// useTransition: mark expensive state updates as non-urgent
function SearchBox({ items }) {
  const [query, setQuery] = useState('');
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();

  function handleSearch(e) {
    setQuery(e.target.value); // urgent: update input immediately

    startTransition(() => {
      // non-urgent: can be interrupted if user types again
      setFilteredItems(items.filter(i => i.includes(e.target.value)));
    });
  }

  return (
    <>
      <input value={query} onChange={handleSearch} />
      {isPending && <Spinner />}
      <List items={filteredItems} />
    </>
  );
}

// useDeferredValue: debounce a derived value
function SearchResults({ query, items }) {
  const deferredQuery = useDeferredValue(query); // lags behind during fast typing

  const filtered = useMemo(
    () => items.filter(i => i.includes(deferredQuery)),
    [items, deferredQuery]
  );

  return <List items={filtered} />;
}

Performance Checklist

  • Profile first — identify actual bottlenecks
  • Memo components that receive stable props frequently
  • Use useCallback for callbacks passed to memoized children
  • Use useMemo for expensive filtered/sorted lists
  • Colocate state — avoid global state for ephemeral UI
  • Split contexts — separate concerns
  • Virtualize long lists (1000+ items)
  • Code-split routes and heavy components
  • Lazy-load images with loading="lazy"
  • Use useTransition for slow state updates (React 18+)

→ Benchmark and measure performance metrics with the Benchmark Builder.