コンテンツにスキップ

Performance Optimization

Comprehensive guide to optimizing React and Next.js applications for production.


Core Optimization Principles

The Three Pillars

  1. Minimize Re-renders - Only update when necessary
  2. Reduce Bundle Size - Ship less JavaScript
  3. Optimize Data Loading - Fetch efficiently

React Performance Optimization

useMemo - Cache Expensive Calculations

Purpose: Remember computed values between renders.

When to use: - Expensive calculations - Complex transformations - Object/array creation passed as props

function ProductList({ products, searchTerm }) {
  // ❌ Recalculated every render
  const filtered = products.filter(p =>
    p.name.includes(searchTerm)
  );

  // ✅ Only recalculates when dependencies change
  const filtered = useMemo(() =>
    products.filter(p => p.name.includes(searchTerm)),
    [products, searchTerm]
  );

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

Real-world example:

function Dashboard({ data }) {
  const statistics = useMemo(() => {
    console.log('Calculating statistics...'); // Only logs when data changes

    return {
      total: data.reduce((sum, item) => sum + item.value, 0),
      average: data.length ? data.reduce((sum, item) => sum + item.value, 0) / data.length : 0,
      max: Math.max(...data.map(item => item.value))
    };
  }, [data]);

  return (
    <div>
      <h2>Total: {statistics.total}</h2>
      <h2>Average: {statistics.average}</h2>
      <h2>Max: {statistics.max}</h2>
    </div>
  );
}

useCallback - Memoize Functions

Purpose: Keep the same function reference between renders.

When to use: - Functions passed to child components - Functions used in dependency arrays - Event handlers in optimized components

function Parent() {
  const [count, setCount] = useState(0);

  // ❌ New function every render
  const handleClick = () => {
    console.log('Clicked');
  };

  // ✅ Same function reference
  const handleClick = useCallback(() => {
    console.log('Clicked');
  }, []);

  return <ExpensiveChild onClick={handleClick} />;
}

With dependencies:

function SearchBox({ onSearch, apiKey }) {
  const handleSearch = useCallback((term) => {
    // Uses latest apiKey
    onSearch(term, apiKey);
  }, [onSearch, apiKey]);

  return <SearchInput onSubmit={handleSearch} />;
}

React.memo - Component Memoization

Purpose: Skip re-rendering when props haven't changed.

// ❌ Re-renders every time parent renders
function ListItem({ item }) {
  return <div>{item.name}</div>;
}

// ✅ Only re-renders when item changes
const ListItem = React.memo(function ListItem({ item }) {
  return <div>{item.name}</div>;
});

Custom comparison:

const UserCard = React.memo(
  function UserCard({ user }) {
    return <div>{user.name} - {user.email}</div>;
  },
  (prevProps, nextProps) => {
    // Return true if props are equal (skip render)
    return prevProps.user.id === nextProps.user.id;
  }
);


Next.js Performance Features

Server Components (RSC)

Benefits: - Zero JavaScript to client - Direct database access - Automatic code splitting

// app/posts/page.tsx - Server Component by default
async function PostsPage() {
  const posts = await db.post.findMany(); // Direct DB access

  return (
    <div>
      {posts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

Dynamic Imports - Code Splitting

Basic usage:

import dynamic from 'next/dynamic';

const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
  loading: () => <p>Loading...</p>,
  ssr: false // Client-only
});

function Page() {
  return <HeavyComponent />;
}

Conditional loading:

function Editor() {
  const [showRichText, setShowRichText] = useState(false);

  const RichTextEditor = useMemo(() =>
    dynamic(() => import('./RichTextEditor')),
    []
  );

  return (
    <div>
      <button onClick={() => setShowRichText(true)}>
        Enable Rich Text
      </button>
      {showRichText && <RichTextEditor />}
    </div>
  );
}

Image Optimization

import Image from 'next/image';

// ✅ Automatic optimization
<Image
  src="/photo.jpg"
  alt="Photo"
  width={800}
  height={600}
  priority // Above-the-fold images
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,..."
/>

Responsive images:

<Image
  src="/hero.jpg"
  alt="Hero"
  fill
  sizes="(max-width: 768px) 100vw, 50vw"
  style={{ objectFit: 'cover' }}
/>

Font Optimization

import { Inter, Roboto_Mono } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter'
});

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

Caching Strategies

ISR (Incremental Static Regeneration)

// Revalidate every 60 seconds
export const revalidate = 60;

async function ProductPage({ params }) {
  const product = await fetchProduct(params.id);
  return <ProductDetails product={product} />;
}

React.cache (Server Components)

import { cache } from 'react';

const getUser = cache(async (id: number) => {
  const user = await db.user.findUnique({ where: { id } });
  return user;
});

// Called multiple times, fetches once
async function UserProfile({ id }) {
  const user = await getUser(id);
  return <div>{user.name}</div>;
}

async function UserBadge({ id }) {
  const user = await getUser(id); // Cached!
  return <span>{user.name}</span>;
}

Fetch Caching

// Cache forever
fetch('https://api.example.com/data', {
  cache: 'force-cache'
});

// Never cache
fetch('https://api.example.com/data', {
  cache: 'no-store'
});

// Revalidate after 60 seconds
fetch('https://api.example.com/data', {
  next: { revalidate: 60 }
});

Streaming and Suspense

Progressive Rendering

import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <div>
      <Header /> {/* Renders immediately */}

      <Suspense fallback={<Skeleton />}>
        <SlowComponent /> {/* Streams when ready */}
      </Suspense>

      <Suspense fallback={<Skeleton />}>
        <AnotherSlowComponent /> {/* Parallel loading */}
      </Suspense>
    </div>
  );
}

Loading States

// app/dashboard/loading.tsx
export default function Loading() {
  return <DashboardSkeleton />;
}

// Automatically wrapped in Suspense by Next.js

Data Fetching Optimization

React Query Best Practices

import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
  const { data, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000, // 5 minutes
    cacheTime: 10 * 60 * 1000, // 10 minutes
    refetchOnWindowFocus: false,
  });

  if (isLoading) return <Skeleton />;
  return <Profile user={data} />;
}

Parallel Queries

function Dashboard() {
  const users = useQuery(['users'], fetchUsers);
  const posts = useQuery(['posts'], fetchPosts);
  const stats = useQuery(['stats'], fetchStats);

  if (users.isLoading || posts.isLoading || stats.isLoading) {
    return <Loading />;
  }

  return (
    <div>
      <UsersList data={users.data} />
      <PostsList data={posts.data} />
      <Statistics data={stats.data} />
    </div>
  );
}

Prefetching

import { useQueryClient } from '@tanstack/react-query';

function ProductList({ products }) {
  const queryClient = useQueryClient();

  return products.map(product => (
    <ProductCard
      key={product.id}
      product={product}
      onMouseEnter={() => {
        // Prefetch details
        queryClient.prefetchQuery(
          ['product', product.id],
          () => fetchProductDetails(product.id)
        );
      }}
    />
  ));
}

Common Performance Anti-Patterns

❌ Problem: Rendering Large Lists

// Bad - renders 10000 items
function List({ items }) {
  return (
    <div>
      {items.map(item => <Item key={item.id} data={item} />)}
    </div>
  );
}

✅ Solution: Virtual Scrolling

import { FixedSizeList } from 'react-window';

function List({ items }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          <Item data={items[index]} />
        </div>
      )}
    </FixedSizeList>
  );
}

❌ Problem: Inline Objects/Arrays

// Bad - new object every render
function Parent() {
  return <Child style={{ margin: 10 }} options={['a', 'b']} />;
}

✅ Solution: Move outside or memoize

const STYLE = { margin: 10 };
const OPTIONS = ['a', 'b'];

function Parent() {
  return <Child style={STYLE} options={OPTIONS} />;
}

// Or with dynamic values
function Parent({ value }) {
  const style = useMemo(() => ({ margin: value }), [value]);
  return <Child style={style} />;
}

❌ Problem: State in Map Loop

// Bad - each item has separate state
function List({ items }) {
  return items.map(item => {
    const [selected, setSelected] = useState(false); // ❌
    return <Item key={item.id} selected={selected} />;
  });
}

✅ Solution: Lift state up

function List({ items }) {
  const [selectedIds, setSelectedIds] = useState(new Set());

  return items.map(item => (
    <Item
      key={item.id}
      selected={selectedIds.has(item.id)}
      onSelect={() => {
        const next = new Set(selectedIds);
        next.add(item.id);
        setSelectedIds(next);
      }}
    />
  ));
}

Bundle Size Optimization

Analyze Bundle

# Next.js built-in analyzer
npm install @next/bundle-analyzer

# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // config
});

# Run analysis
ANALYZE=true npm run build

Tree Shaking

// ❌ Imports entire library
import _ from 'lodash';
const result = _.chunk(array, 3);

// ✅ Import only what you need
import chunk from 'lodash/chunk';
const result = chunk(array, 3);

// ✅ Or use modern alternatives
const result = array.reduce((acc, item, i) => {
  const chunkIndex = Math.floor(i / 3);
  acc[chunkIndex] = [...(acc[chunkIndex] || []), item];
  return acc;
}, []);

Lazy Load Heavy Dependencies

// Only load when needed
async function exportToPDF() {
  const { jsPDF } = await import('jspdf');
  const doc = new jsPDF();
  // ...
}

Performance Monitoring

Web Vitals

// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  );
}

Custom Metrics

import { useReportWebVitals } from 'next/web-vitals';

export function WebVitals() {
  useReportWebVitals((metric) => {
    console.log(metric);
    // Send to analytics
    if (metric.label === 'web-vital') {
      analytics.track(metric.name, metric.value);
    }
  });
  return null;
}

Quick Wins Checklist

  • Enable React.StrictMode
  • Use Next.js Image component
  • Implement proper loading states
  • Add Suspense boundaries
  • Memoize expensive calculations
  • Use Server Components by default
  • Enable ISR for static content
  • Lazy load heavy components
  • Optimize images (WebP, sizing)
  • Minimize client-side JavaScript
  • Use font optimization
  • Enable bundle analyzer
  • Monitor Web Vitals
  • Implement virtualization for long lists

Performance Targets

Metric Target Good Needs Improvement
LCP (Largest Contentful Paint) < 2.5s < 2.5s > 4.0s
FID (First Input Delay) < 100ms < 100ms > 300ms
CLS (Cumulative Layout Shift) < 0.1 < 0.1 > 0.25
TTFB (Time to First Byte) < 600ms < 600ms > 1.5s
Total Blocking Time < 200ms < 300ms > 600ms

Resources