Skip to main content
Engineering Reference · 2026

Performance & Accessibility in React

A production-grade handbook for building high-performance and fully accessible React applications. Covers Core Web Vitals, rendering optimization, WCAG 2.1, and modern Next.js App Router patterns.

~45 min readIntermediate → AdvancedReact 19 · Next.js 15 · WCAG 2.1 AA
01 / Introduction

Two Concerns That Cannot Be Retrofitted

Performance and accessibility are the two most commonly deferred concerns in React development: and the two most expensive to fix after the fact. They share a critical characteristic: both are architectural in nature. You cannot reliably bolt them on after a feature ships. They must be woven into how you design components, structure data flow, and choose rendering strategies from the beginning.

Performance is not just about fast page loads. It is about keeping the browser responsive under real-world conditions: large data sets, low-end hardware, slow networks, and complex interactions happening simultaneously. Accessibility is not just about screen readers. It is about ensuring every user: regardless of disability, input device, or context: can navigate and operate your application with full fidelity.

The shared root cause: Most React performance problems and most React accessibility failures trace back to the same source: treating the DOM as an afterthought. Semantic structure, render efficiency, and user interaction models are all shaped by how you compose your component tree.

How React Apps Commonly Fail

  • Performance: Entire subtrees re-rendering on every keystroke. Client-side fetching waterfalls that should be server-side. Unvirtualized lists with 5,000 DOM nodes. Heavy third-party libraries imported without code splitting.
  • Accessibility: Interactive elements built from <div> and <span> with no keyboard support. Modals that trap visual focus but not keyboard focus. Form errors that appear visually but are never announced to screen readers. Missing focus management after route transitions.
  • Both: Deep, semantically meaningless DOM trees that are slow to reconcile and impossible to navigate with assistive technology.
02 / React Performance Model

How React Renders: and Why It Re-Renders

The Render Mental Model

A React render is a function call, not a DOM update. When React renders a component, it calls the component function and gets back a description of what the UI should look like (a React element tree). It then compares that description to the previous one (reconciliation) and applies only the minimal set of DOM changes needed.

This means: rendering a component is cheap. Committing DOM changes is what costs. The performance concern is not eliminating renders: it is preventing renders that produce identical output, wasting CPU on comparison work that will never result in a DOM change.

When Does a Component Re-Render?

  • Its own state changes: a setState call triggers a re-render of that component and all its descendants.
  • Its parent re-renders: by default, React re-renders all children when a parent renders, even if none of their props changed.
  • A context it consumes changes: any component that reads a context value re-renders when that value changes, regardless of which part of the context it reads.
  • A custom hook it uses triggers a state update: hooks that calluseState internally will cause the consuming component to re-render.
tsxUnderstanding re-render propagation
function ParentComponent() {
  const [count, setCount] = useState(0);

  return (
    <>
      <button onClick={() => setCount((c) => c + 1)}>Clicks: {count}</button>

      {/* ✗ Re-renders on every click even though it receives no count prop */}
      <ExpensiveComponent />

      {/* ✓ Only re-renders if 'items' reference changes */}
      <MemoizedList items={staticItems} />
    </>
  );
}

// Without memo: re-renders every time parent does
function ExpensiveComponent() { /* ... */ }

// With memo: skips re-render if props are shallowly equal
const MemoizedList = React.memo(function MemoizedList({ items }: { items: Item[] }) {
  return <ul>{items.map((i) => <li key={i.id}>{i.name}</li>)}</ul>;
});

Reconciliation: What Actually Costs

React's Fiber reconciler performs a tree diffing algorithm. Its cost scales with the depth and breadth of the subtree being reconciled. A flat, shallow tree with 10 components reconciles far faster than a nested tree with 200 components, even if both render identically. This is a compounding architectural concern: every layer of abstraction you add has a reconciliation cost.

Keys in lists tell the reconciler which items are which across renders. A missing or unstable key (like an array index for a reorderable list) forces React to recreate DOM nodes unnecessarily: a common source of both performance regression and focus management bugs.

Hydration Costs in Next.js

SSR sends HTML to the browser immediately (good for LCP), but the page is not interactive until React has "hydrated" it: re-running all client component code to attach event listeners. During this window, the page looks interactive but is not. The hydration cost scales directly with the amount of client-side JavaScript. This is the primary motivation for React Server Components: components that render on the server never hydrate, never ship component JS, and never block the main thread.

Mental model for RSC performance: every server component you have is a component the browser does not need to re-execute, re-render, or hydrate. That is direct, compounding Time-to-Interactive improvement.
03 / Optimization Techniques

The Performance Toolkit

Profile before optimizing. Every technique in this section introduces complexity. Apply them only after the React Profiler or DevTools Performance tab has confirmed an actual bottleneck. Premature memoization is one of the most common sources of subtle bugs in React codebases.

Memoization: React.memo, useMemo, useCallback

All three tools trade memory for CPU: they store a previous result to avoid recomputing. They are only beneficial when the cost of recomputing exceeds the cost of the comparison React makes on every render.

tsxReact.memo: skip re-render if props are shallowly equal
// ✓ USE: component is expensive to render, receives stable props
const DataTable = React.memo(function DataTable({
  rows,
  onRowClick,
}: {
  rows: Row[];
  onRowClick: (id: string) => void;
}) {
  return (
    <table>
      {rows.map((row) => (
        <tr key={row.id} onClick={() => onRowClick(row.id)}>
          <td>{row.name}</td>
        </tr>
      ))}
    </table>
  );
});

// ✗ AVOID: component is trivial, memo overhead exceeds savings
const Label = React.memo(({ text }: { text: string }) => <span>{text}</span>);
tsxuseMemo: memoize expensive computed values
function ProductList({ products, searchQuery }: Props) {
  // ✓ USE: sorting/filtering large arrays that re-run on every render
  const filtered = useMemo(
    () =>
      products
        .filter((p) => p.name.toLowerCase().includes(searchQuery.toLowerCase()))
        .sort((a, b) => a.name.localeCompare(b.name)),
    [products, searchQuery]
  );

  // ✗ AVOID: trivial computations: the memo bookkeeping costs more than the work
  const label = useMemo(() => `${products.length} items`, [products.length]);

  return <ul>{filtered.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}
tsxuseCallback: stable function references for memoized children
function Parent() {
  const [items, setItems] = useState<Item[]>([]);

  // ✓ USE: passed to a React.memo child: prevents unnecessary re-renders
  const handleDelete = useCallback((id: string) => {
    setItems((prev) => prev.filter((item) => item.id !== id));
  }, []); // empty deps: setItems is stable

  // ✗ AVOID: not passed to memo'd child: the useCallback overhead is pure waste
  const handleClick = useCallback(() => console.log("clicked"), []);

  return <MemoizedList items={items} onDelete={handleDelete} />;
}

Code Splitting and Dynamic Imports

Code splitting defers loading JavaScript until it is actually needed. Next.js handles route-level splitting automatically; you are responsible for component-level splits for heavy, conditionally-rendered UI.

tsxnext/dynamic: defer heavy component loading
import dynamic from "next/dynamic";

// ✓ Heavy chart library: only loads when the component renders
const RevenueChart = dynamic(() => import("@/components/RevenueChart"), {
  loading: () => <ChartSkeleton />,
  ssr: false, // chart library uses browser APIs: disable SSR
});

// ✓ Modal: only loads when opened (conditional render gates the import)
const EditUserModal = dynamic(() => import("@/components/EditUserModal"), {
  loading: () => null,
});

// ✓ Rich text editor: large bundle, only needed on edit pages
const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
  loading: () => <EditorSkeleton />,
  ssr: false,
});

Virtualization for Long Lists

Rendering thousands of DOM nodes is the fastest way to make a React app feel sluggish. Virtualization renders only the rows visible in the viewport. Use it when your list exceeds ~100 items.

tsx@tanstack/react-virtual: windowed list
"use client";

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

export function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48, // estimated row height in px
    overscan: 5,            // rows rendered outside viewport (buffer)
  });

  return (
    // Container must have a fixed height and overflow: auto
    <div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
      {/* Total scroll height: keeps scrollbar accurate */}
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.index}
            style={{
              position: "absolute",
              top: virtualRow.start,
              height: virtualRow.size,
              width: "100%",
            }}
          >
            {items[virtualRow.index].name}
          </div>
        ))}
      </div>
    </div>
  );
}
Virtualization and accessibility: virtualized lists require extra ARIA work to be accessible. Add role="list" to the container,role="listitem" to rows, and test keyboard navigation carefully. Screen readers may not read items that are not in the DOM.

Preventing Waterfall Requests

Client-side data fetching in useEffect is serial by default: each component fetches after it renders, triggering children that also fetch. Move data fetching to server components where possible; they execute in parallel on the server before any HTML is sent.

tsxParallel server-side fetching: no waterfall
// app/dashboard/page.tsx: Server Component
export default async function DashboardPage() {
  // ✓ All three queries run in parallel: no waterfall
  const [metrics, recentOrders, teamActivity] = await Promise.all([
    fetchMetrics(),
    fetchRecentOrders({ limit: 10 }),
    fetchTeamActivity({ days: 7 }),
  ]);

  return (
    <div>
      <MetricsGrid metrics={metrics} />
      <OrdersTable orders={recentOrders} />
      <ActivityFeed activity={teamActivity} />
    </div>
  );
}

// Each section can also stream independently:
export default function DashboardPage() {
  return (
    <div>
      <Suspense fallback={<MetricsSkeleton />}>
        <MetricsSection />  {/* fetches independently */}
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <OrdersSection />   {/* streams in when ready */}
      </Suspense>
    </div>
  );
}

Image Optimization

Images are the leading cause of poor LCP scores. Next.js <Image> handles format conversion, responsive sizing, lazy loading, and prevents layout shift automatically.

tsxNext.js Image component: correct usage
import Image from "next/image";

// ✓ Above-the-fold hero image: mark as priority (disables lazy loading)
<Image
  src="/hero.jpg"
  alt="Dashboard overview showing key metrics"
  width={1200}
  height={630}
  priority         // <-- critical for LCP images
  sizes="100vw"
/>

// ✓ Below-the-fold product image: lazy load (default)
<Image
  src={product.imageUrl}
  alt={product.name}
  width={400}
  height={300}
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>

// ✗ Missing width/height causes CLS (layout shift)
<img src={product.imageUrl} alt={product.name} />
04 / Measuring Performance

Measure First, Optimize Second

The most important performance rule: you cannot optimize what you have not measured. Guessing at bottlenecks leads to adding complexity (memoization, splitting, caching) where it has no effect, while actual bottlenecks remain.

Core Web Vitals

Core Web Vitals are Google's standardised metrics for user-centric page performance. They directly influence search ranking and reflect real user experience. INP replaced FID as a Core Web Vital in March 2024.

LCP

Largest Contentful Paint

Good< 2.5s
Needs work2.5–4s
Poor> 4s

CLS

Cumulative Layout Shift

Good< 0.1
Needs work0.1–0.25
Poor> 0.25

INP

Interaction to Next Paint

Good< 200ms
Needs work200–500ms
Poor> 500ms

Collecting Web Vitals

tsxapp/layout.tsx: report Web Vitals from the client
// components/WebVitals.tsx
"use client";

import { useEffect } from "react";
import { onCLS, onINP, onLCP } from "web-vitals";

export function WebVitals() {
  useEffect(() => {
    onLCP((metric) => sendToAnalytics({ name: metric.name, value: metric.value }));
    onCLS((metric) => sendToAnalytics({ name: metric.name, value: metric.value }));
    onINP((metric) => sendToAnalytics({ name: metric.name, value: metric.value }));
  }, []);

  return null;
}

function sendToAnalytics(metric: { name: string; value: number }) {
  // Send to your RUM platform: Datadog, New Relic, custom endpoint, etc.
  navigator.sendBeacon("/api/vitals", JSON.stringify(metric));
}

React Profiler

The React DevTools Profiler records every render in a session and shows which components rendered, how long they took, and why they rendered. This is the correct first step when investigating slow UI interactions.

  • Open React DevTools → Profiler tab → Record → trigger the slow interaction → Stop
  • Look for components with long render durations (yellow/red flame chart bars)
  • Look for components that re-render unexpectedly (especially when their visible output does not change)
  • The "Why did this render?" tooltip explains whether props, state, context, or a parent caused the render

Common Measurement Mistakes

  • Measuring in development mode:React's development build is significantly slower than production due to extra checks. Always performance-test against a production build (yarn build && yarn start).
  • Testing only on your own machine: use Chrome DevTools CPU throttle (4x or 6x slowdown) to simulate mid-range Android hardware, which is where most performance problems manifest.
  • Optimising Lighthouse score instead of real user metrics: Lighthouse runs in a controlled lab environment. RUM data shows what your actual users experience.
  • Measuring a cold cache:repeat measurements are usually faster due to browser caching. Measure with "Disable cache" checked for first-visit scenarios.
05 / Accessibility in React

Building for Every User

Accessibility (a11y) means your application works for people who use screen readers, keyboard-only navigation, switch access devices, voice control, high contrast modes, or reduced motion preferences. WCAG 2.1 AA is the minimum compliance standard for most public-facing products in 2026 and is legally required in many jurisdictions.

Semantic HTML: The Foundation

Semantic HTML is the single highest-leverage accessibility technique. Native HTML elements come with built-in keyboard behaviour, ARIA roles, and browser accessibility tree integration: for free. Building the same thing with <div> requires manually re-implementing all of that.

tsxSemantic elements: use the right tool
// ✗ Inaccessible: div-based interactive element
// - Not focusable with Tab
// - Not activatable with Enter/Space
// - No role conveyed to screen readers
<div onClick={handleDelete} className="delete-btn">Delete</div>

// ✓ Accessible: native button
// - Focusable by default
// - Enter/Space activates it
// - Screen reader announces "Delete, button"
<button type="button" onClick={handleDelete}>Delete</button>

// ✗ Inaccessible: no heading structure, no landmark
<div className="header">
  <div className="title">Settings</div>
</div>

// ✓ Accessible: landmarks + heading hierarchy
<header>
  <h1>Settings</h1>
</header>
<main>
  <section aria-labelledby="profile-heading">
    <h2 id="profile-heading">Profile</h2>
  </section>
</main>

ARIA: When and How to Use It

The first rule of ARIA: do not use ARIA if a native HTML element can communicate the same semantics. ARIA attributes supplement the accessibility tree: they do not add behaviour. A role="button" on a <div>tells a screen reader "this is a button" but does not make it keyboard focusable or activatable. You still have to implement those yourself.

tsxARIA: correct applications
// Live regions: announce dynamic content to screen readers
// polite = waits for user to be idle; assertive = interrupts immediately
<div aria-live="polite" aria-atomic="true">
  {statusMessage} {/* updated by state */}
</div>

// Expandable controls
<button
  aria-expanded={isOpen}
  aria-controls="menu-id"
  onClick={() => setIsOpen((v) => !v)}
>
  Options
</button>
<ul id="menu-id" hidden={!isOpen} role="menu">
  <li role="menuitem"><a href="/settings">Settings</a></li>
</ul>

// Decorative images: hide from screen readers
<img src="/decorative-wave.svg" alt="" aria-hidden="true" />

// Visible labels elsewhere: reference with aria-labelledby
<h2 id="section-title">Recent Orders</h2>
<table aria-labelledby="section-title">...</table>

Keyboard Navigation

All interactive UI must be operable with a keyboard alone. The tab order should follow a logical reading order (which matches DOM order by default: avoid overriding this with positive tabindex values).

  • Tab: move to the next focusable element
  • Shift+Tab: move to the previous focusable element
  • Enter: activate buttons, links
  • Space: activate buttons, checkboxes
  • Arrow keys: navigate within composite widgets (menus, tabs, radio groups)
  • Escape: dismiss dialogs, close menus
tsxRoving tabindex: keyboard-navigable widget
"use client";

import { useRef, useState, KeyboardEvent } from "react";

// Tabs, toolbars, menus: only one item in the tab sequence at a time.
// Arrow keys move focus between items.
export function TabList({ tabs }: { tabs: Tab[] }) {
  const [activeIndex, setActiveIndex] = useState(0);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);

  const handleKeyDown = (e: KeyboardEvent, index: number) => {
    let next = index;
    if (e.key === "ArrowRight") next = (index + 1) % tabs.length;
    if (e.key === "ArrowLeft") next = (index - 1 + tabs.length) % tabs.length;
    if (e.key === "Home") next = 0;
    if (e.key === "End") next = tabs.length - 1;

    if (next !== index) {
      e.preventDefault();
      setActiveIndex(next);
      refs.current[next]?.focus();
    }
  };

  return (
    <div role="tablist">
      {tabs.map((tab, i) => (
        <button
          key={tab.id}
          ref={(el) => { refs.current[i] = el; }}
          role="tab"
          aria-selected={i === activeIndex}
          aria-controls={tab.panelId}
          tabIndex={i === activeIndex ? 0 : -1}
          onKeyDown={(e) => handleKeyDown(e, i)}
          onClick={() => setActiveIndex(i)}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}

Focus Management

When content changes dynamically: a modal opens, a route changes, a step in a wizard completes: keyboard and screen reader users need their focus to move to the right place. If it does not, they are left in a confusing state.

tsxModal with correct focus management
"use client";

import { useEffect, useRef } from "react";

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;

    if (isOpen) {
      // showModal() traps focus and handles Escape natively
      dialog.showModal();
    } else {
      dialog.close();
    }
  }, [isOpen]);

  return (
    // <dialog> natively: traps focus, handles Escape, aria-modal semantics
    <dialog
      ref={dialogRef}
      aria-labelledby="modal-title"
      onClose={onClose}
    >
      <h2 id="modal-title">{title}</h2>
      <div>{children}</div>
      <button type="button" onClick={onClose} autoFocus>
        Close
      </button>
    </dialog>
  );
}

Accessible Forms

Forms are where most React accessibility failures concentrate. Every input must have a programmatically associated label. Every error must be announced to screen readers. Validation feedback must not rely on color alone.

tsxAccessible form: labels, errors, ARIA
"use client";

import { useState } from "react";

export function SignUpForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});

  return (
    <form noValidate onSubmit={handleSubmit}>
      <div>
        {/* htmlFor links label to input by matching id */}
        <label htmlFor="email">
          Email address
          <span aria-hidden="true"> *</span>
          <span className="sr-only"> (required)</span>
        </label>
        <input
          id="email"
          type="email"
          autoComplete="email"
          required
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? "email-error" : undefined}
        />
        {errors.email && (
          // role="alert" announces immediately; alternatively use aria-live on a container
          <p id="email-error" role="alert" className="error">
            {errors.email}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          type="password"
          autoComplete="new-password"
          required
          aria-required="true"
          aria-invalid={!!errors.password}
          aria-describedby="password-hint"
        />
        {/* Hint text always shown: linked regardless of error state */}
        <p id="password-hint" className="hint">
          At least 8 characters, one uppercase, one number
        </p>
      </div>

      <button type="submit">Create account</button>
    </form>
  );
}

function handleSubmit() { /* ... */ }
06 / Performance + Accessibility

They Reinforce Each Other

Performance and accessibility are often treated as separate concerns, but they share the same root cause when they fail: a poorly-structured DOM. Fixing one often improves the other.

PatternPerformance ImpactAccessibility Impact
Semantic HTML vs div soupShallower tree = faster reconciliationNative keyboard, roles, landmarks
Correct heading hierarchyPredictable render patternsAT users navigate by headings
Fixed image dimensionsPrevents CLS (layout shift)Stable layout for magnification users
Stable keys in listsFewer DOM mutationsFocus not lost on re-render
Streaming with SuspenseFaster progressive renderingNeeds aria-live for AT announcements
Virtualized long listsDramatically fewer DOM nodesRequires explicit ARIA list semantics
Using <dialog> for modalsNo JS focus-trap overheadBuilt-in aria-modal, Escape, focus trap
The dual-concern rule: when reviewing a component for performance, also check its semantic structure. When reviewing for accessibility, also check its render behaviour. The two reviews reinforce each other and the cost is marginal.
07 / Anti-Patterns

What to Actively Avoid

Performance Anti-Patterns

1. Premature memoization

Wrapping every component in React.memo and every callback in useCallback without profiling first. Each memo adds a shallow comparison on every render. If the comparison cost exceeds the render cost (common for simple components), you have made things slower, not faster.

2. Putting everything in global state

A single large Zustand or Context store that updates frequently causes every consumer to re-render on every change. Split stores by domain and update frequency. Ephemeral UI state (is a dropdown open) belongs in local useState, not global state.

3. useEffect waterfall fetching

tsxAnti-pattern: fetch waterfall in client component
// ✗ Each component fetches after mounting: serial round trips
function OrderDetail({ orderId }: { orderId: string }) {
  const [order, setOrder] = useState(null);
  const [customer, setCustomer] = useState(null);

  useEffect(() => {
    fetchOrder(orderId).then(setOrder);
  }, [orderId]);

  useEffect(() => {
    if (order) fetchCustomer(order.customerId).then(setCustomer); // waits for order!
  }, [order]);
}

4. Missing key props or index-as-key

tsxAnti-pattern: unstable keys cause DOM thrash
// ✗ Index as key: re-ordering list forces React to recreate all DOM nodes
{items.map((item, index) => <Row key={index} item={item} />)}

// ✓ Stable unique ID: React reuses existing DOM nodes
{items.map((item) => <Row key={item.id} item={item} />)}

Accessibility Anti-Patterns

5. div and span as interactive elements

tsxAnti-pattern: non-semantic interactive elements
// ✗ Not keyboard focusable, no role, onClick does not fire on Enter
<div onClick={onSubmit} className="submit-btn">Submit order</div>

// ✗ onClick on a non-interactive container
<div onClick={() => navigate("/product/1")}>
  <h3>Product Name</h3>
  <p>Description</p>
</div>

// ✓ Use a link with appropriate semantics
<a href="/product/1">
  <h3>Product Name</h3>
  <p>Description</p>
</a>

6. Missing focus management in modals

A modal that opens without moving focus to its content leaves keyboard users trapped outside it. A modal that closes without returning focus to the trigger element leaves keyboard users lost. Both are WCAG failures and common React bugs.

7. Errors announced only visually

Form validation errors that appear with a red border and error text are invisible to screen reader users unless the error text is programmatically associated with the input (via aria-describedby) or announced via a live region.

8. Unvirtualized long lists

A list of 2,000 items renders 2,000 DOM nodes. This causes slow initial render, slow scroll performance, and can exceed the practical limit of what screen readers announce coherently. Virtualize lists longer than ~100 items.

08 / Architecture Patterns

Designing for Performance and Accessibility

Server-First Rendering

The highest-leverage performance pattern in 2026: render as much as possible on the server. Server components produce HTML with zero client JS, no hydration cost, and direct server-side data access. The client receives rendered content and only has to hydrate the interactive parts.

This also benefits accessibility: server-rendered HTML is available to screen readers immediately, without waiting for JavaScript to execute. LCP scores improve directly because the browser has real content in the initial HTML.

Leaf-Level Client Components

Push "use client" to the smallest possible leaf nodes. A product card that is mostly static but has an interactive button should be a server component (the card) with a single client component (the button). This minimises the amount of JS that needs to hydrate.

Accessibility-First Component Design

Design components using native HTML semantics as the foundation, then style them. This is the opposite of the common pattern: div structure first, ARIA applied later. The ARIA-first approach produces simpler, lighter components that work correctly with no additional JS.

tsxDesign pattern: semantic-first disclosure widget
// ✓ Built on native <details>/<summary>: keyboard, screen reader, animation-ready
// No JS required for the core behaviour

export function Accordion({ title, children }: AccordionProps) {
  return (
    <details>
      <summary>
        {/* summary is natively focusable, toggled by Enter/Space */}
        {title}
      </summary>
      <div>{children}</div>
    </details>
  );
}

// For custom styling/animation, add "use client" and manage open state,
// but keep the semantic structure: button + region + aria-expanded
"use client";

export function AnimatedAccordion({ title, children }: AccordionProps) {
  const [open, setOpen] = useState(false);
  const panelId = useId();

  return (
    <div>
      <button
        type="button"
        aria-expanded={open}
        aria-controls={panelId}
        onClick={() => setOpen((v) => !v)}
      >
        {title}
      </button>
      <div id={panelId} hidden={!open} role="region" aria-label={title}>
        {children}
      </div>
    </div>
  );
}

Reduced Motion Respect

tsxRespecting prefers-reduced-motion
"use client";

import { useReducedMotion } from "framer-motion"; // or CSS media query

export function AnimatedCard({ children }: { children: React.ReactNode }) {
  const prefersReduced = useReducedMotion();

  return (
    <motion.div
      initial={prefersReduced ? {} : { opacity: 0, y: 20 }}
      animate={prefersReduced ? {} : { opacity: 1, y: 0 }}
      transition={{ duration: prefersReduced ? 0 : 0.3 }}
    >
      {children}
    </motion.div>
  );
}

// Or with CSS alone (often preferred: no JS required):
// @media (prefers-reduced-motion: reduce) { .animated { animation: none; } }
09 / Checklist

Pre-Ship Checklist

Run this checklist before shipping a new page or significant feature. Most items are fast to verify; others require a one-time setup in your CI pipeline.

Rendering Performance

  • React Profiler shows no unexpected re-renders
  • No component re-renders more than once per interaction
  • Context consumers isolated to what they actually need
  • memo / useCallback only where profiler confirmed benefit
  • Infinite re-render loops caught and resolved
  • All list items have stable, unique keys

Bundle & Loading

  • Heavy components code-split with dynamic()
  • No unused imports from large libraries
  • Bundle analyzer shows no unexpected large modules
  • Route-level code split working as expected
  • Third-party scripts loaded with appropriate strategy
  • Fonts loaded with next/font (no CLS)

Core Web Vitals

  • LCP image marked with priority prop
  • All images have explicit width + height (no CLS)
  • No layout shifts from late-loading content
  • INP < 200ms for primary interactions
  • Lighthouse performance score ≥ 90 on mobile
  • Web Vitals reporting wired to analytics

Semantic Correctness

  • One h1 per page, heading hierarchy logical
  • Landmark regions present (main, nav, header, footer)
  • All interactive elements are buttons, links, or form controls
  • No positive tabindex values used
  • Decorative images have empty alt text
  • Informative images have descriptive alt text

Keyboard & Focus

  • All interactive elements reachable by Tab
  • Focus indicator visible (not outline: none)
  • Modals trap focus and return it on close
  • Route changes move focus to main heading
  • Custom widgets implement arrow-key navigation
  • Escape dismisses overlays and menus

Forms & Screen Readers

  • Every input has an associated label
  • Error messages linked via aria-describedby
  • Required fields marked with aria-required
  • Dynamic content changes use aria-live
  • Color is not the only error indicator
  • Lighthouse accessibility score ≥ 90
10 / Decision Framework

When and How to Prioritise

Optimise performance when...

  • Profiler confirms a real bottleneck (not assumed)
  • Core Web Vitals are below good thresholds
  • User research shows performance-related abandonment
  • A new feature adds significant JS bundle weight
  • A list or table exceeds 100 rendered rows
  • A page has a high bounce rate with slow LCP

Do NOT optimise when...

  • No profiling data confirms a problem
  • The component is trivial and rarely re-renders
  • Memoization would add complexity to a simple flow
  • Metrics are already within good thresholds
  • The optimization is a response to instinct, not data
  • It would ship late to address a theoretical problem

Accessibility Priority

IssuePriorityReason
Keyboard-inaccessible interactive elementCritical: block shipCompletely excludes keyboard users
Modal without focus trapCritical: block shipWCAG 2.1 failure; users cannot operate the modal
Form inputs without labelsCritical: block shipScreen reader users cannot identify fields
Missing alt text on imagesHigh: fix in sprintWCAG failure; decorative images need empty alt
Insufficient color contrastHigh: fix in sprintAffects low-vision users; WCAG AA requires 4.5:1
Missing focus indicatorHigh: fix in sprintKeyboard users cannot see where focus is
Missing aria-live for dynamic contentMedium: next sprintScreen readers miss updates; confusing but operable
11 / Tools Ecosystem

The 2026 Toolchain

React DevTools Profiler

Performance

Records renders in a session. Shows component render time, render cause (props/state/context/parent), and flame graph. First step for any performance investigation.

Chrome DevTools Performance

Performance

Frame-level timeline of JS execution, layout, and paint. Use CPU throttle to simulate mid-range hardware. Identifies long tasks that block the main thread.

Lighthouse

Performance + A11y

Automated audits for performance, accessibility, SEO, and best practices. Scores 0–100. Run in CI with lighthouse-ci to catch regressions.

web-vitals

Performance

Google's official library for measuring LCP, CLS, and INP from real users. Integrate with any analytics endpoint. The authoritative source for RUM data.

@next/bundle-analyzer

Bundle

Visual treemap of your JS bundle. Reveals which modules consume the most bytes. Run when bundle size increases unexpectedly or before a release.

eslint-plugin-jsx-a11y

Accessibility

Static analysis for common accessibility violations in JSX: missing alt, interactive elements without handlers, missing ARIA attributes. Runs in your editor and CI. Catches issues before they reach the browser.

axe-core / @axe-core/react

Accessibility

Runtime accessibility testing. Reports WCAG violations in the DevTools console during development. Integrate with Playwright or Cypress for automated a11y testing in CI.

@tanstack/react-virtual

Performance

Headless virtualizer for long lists and grids. Framework-agnostic core with React bindings. Supports dynamic item sizes and horizontal/vertical scrolling.

12 / Real-World Recommendations

Production Baselines for 2026

Startup / Small Team

ConcernMinimum Viable Setup
Performance baselineLighthouse CI in GitHub Actions; fail on score < 80
RUMweb-vitals + Vercel Analytics or a free tier RUM tool
Bundle monitoring@next/bundle-analyzer run on demand before releases
Accessibility linteslint-plugin-jsx-a11y in ESLint config; enforced in CI
Manual a11y testingTab through every new feature before shipping; test with VoiceOver/NVDA
Imagesnext/image everywhere; all LCP images have priority

Enterprise / Large Team

ConcernProduction Setup
Performance budgetsDefined in lighthouse-ci config; block merges that regress metrics
RUMDatadog, New Relic, or Sentry Performance; p75 Core Web Vitals dashboards
Automated a11y testingaxe-core in Playwright test suite; coverage for all critical user flows
Design systemAccessible component library (React Aria, Radix UI) as foundation
WCAG auditQuarterly manual audit with AT (VoiceOver, NVDA, JAWS) by QA or vendor
Rendering strategyAll data-display pages server-rendered; client islands at leaf level
The non-negotiable baseline in 2026: eslint-plugin-jsx-a11y in CI (catches obvious violations before review), next/image for all images (prevents CLS and optimises formats automatically), and Lighthouse CI with a score floor. Everything above this is incremental improvement. The baseline takes less than a day to set up and prevents entire categories of regressions from ever merging.

Recommended Component Library Strategy

Building interactive components from scratch (modals, dropdowns, date pickers, comboboxes) is extremely difficult to do accessibly. The keyboard behaviour, focus management, and ARIA patterns required are complex and well-specified. For most teams, the correct approach is to use a headless accessible component library as the foundation:

  • Radix UI: unstyled, accessible primitives for React. Dialog, Dropdown, Select, Tabs, etc. WCAG-compliant out of the box. Bring your own styles.
  • React Aria (Adobe): full-featured accessible hooks and components. More comprehensive than Radix; higher learning curve. Ideal for complex enterprise design systems.
  • shadcn/ui: pre-styled components built on Radix UI primitives. Copy-paste into your codebase. Good accessibility baseline with customisable styles.

The rule: if an interaction pattern exists in ARIA Authoring Practices (APG), use a library that has already implemented it correctly rather than building your own.