# Building a Large-Scale Frontend Fast and Accessibly with React 19: Code Splitting, React Compiler, Bundle Optimization, and a11y/i18n in Practice

> An implementation guide for building a large-scale SPA/web app fast and accessibly with React 19. Shrink the initial bundle with route-level code splitting via lazy+Suspense, auto-memoize with React Compiler, optimize bundles with manualChunks, handle a11y (ARIA/keyboard operation/focus management/prefers-reduced-motion), and scale multilingual support — all with real code faithful to the official docs.

- Published: 2026-06-24
- Author: 友田 陽大
- Tags: React, パフォーマンス, フロントエンド, a11y, TypeScript
- URL: https://tomodahinata.com/en/blog/react-19-large-scale-frontend-code-splitting-compiler-a11y-guide
- Category: Frontend
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-16-app-router-cache-components-data-fetching

## Key points

- Large-frontend trouble boils down to three classes — bloated initial bundle, unnecessary re-renders, and lack of a11y — and the 1st and 3rd share the same root
- Split every route with lazy + Suspense, and always declare lazy at the top level (in-function declaration causes state resets)
- Make manual useMemo / useCallback / memo unnecessary with React Compiler. The premise is compliance with the Rules of React
- No ARIA is better than bad ARIA. Semantic HTML first; keep ARIA to a minimal complement
- For i18n, treat one language as canonical and constrain the others to the same shape with type safety to detect missing translations at build time, and make the reading-aloud language explicit with the lang attribute

---

"More screens have been added, and somehow it feels heavy" — large-frontend trouble usually starts from this vague one-liner. But break it down, and the enemy is surprisingly clear. **The initial bundle is bloated / unnecessary re-renders are firing / it can't even be used with a keyboard or a screen reader in the first place.** These three.

And what tends to get overlooked is that the last one ("can't be used (lack of accessibility)") is also **the flip side of the same design problem as performance**. A non-semantic DOM hits SEO, performance, and accessibility all at once. "Fast" and "usable" are not separate jobs.

This article is an implementation guide for building a **large-scale SPA / web app** fast, accessibly, and multilingually with React 19. As the subject, I'll weave in the design decisions from a project I built with a team — a [restaurant-matching site for foreign tourists](/case-studies/restaurant-matching) — where a Tinder-style swipe UI in React + Framer Motion was made accessible and supported four languages (Japanese, English, Chinese, Korean) **with a manual implementation, without an i18n library**.

> **The rules of this article**: API names, behaviors, and attribute names are based on the **official docs of React / MDN / W3C (as of June 2026)**. Since specs are revised, always check the latest at the [official links](#references-official-documentation) at the end before going to production. And as a fundamental premise — **speed and accessibility are not a bolt-on "support" but a "design" from the start**. Secrets in code are assumed to be in environment variables, and I don't use `any`.

> **The scope of this article**: what we cover is **rendering performance and accessibility**. The cache design of server state (`staleTime` / invalidation / optimistic updates) is a separate problem, so it's split into the [TanStack Query v5 Practical Guide](/blog/tanstack-query). Next.js's server-side caching is complemented by the [Next.js 16 App Router Practical Guide](/blog/nextjs-16-app-router-cache-components-data-fetching). **This article deliberately keeps the data-fetching talk to a minimum** and concentrates on rendering and a11y.

---

## 0. Mental Model: The Three Enemies of a Large Frontend

Before getting into design, let's share a map. The reasons a large frontend becomes heavy and hard to use boil down to roughly these three classes.

| Enemy | Symptom | Design-level move |
| --- | --- | --- |
| ① Bloated initial bundle | Slow first render / TTI grows | **Route-level code splitting** (`lazy` + `Suspense`) |
| ② Unnecessary re-renders | Heavy / janky on every interaction | **React Compiler's auto-memoization** (makes manual memo unnecessary) |
| ③ Lack of accessibility | Can't operate by keyboard / can't be read aloud | **Semantic-HTML first, minimal ARIA, care for focus and motion** |

What's important is that **① and ③ share the same root**. For example, "a button built with `<div onClick>`" (a) can't be used unless you re-implement all of the keyboard operation, focus, and role that a native `<button>` has (③), and (b) as a result, the code and JS grow (①). Conversely, choose the right semantic HTML and both get lighter at the same time. **Treat a11y as a design constraint on par with features** — this is the through-line of this article.

So let's crush them in order, starting with ①.

---

## 1. Shrink the Initial Bundle: Route Splitting with `lazy` + `Suspense`

### 1.1 First, the Principle: "Don't Send Code the First Screen Doesn't Need First"

The biggest reason a large app's initial bundle gets fat is that **components for all screens are bundled into one**. Even though the user is only viewing the top page, you're forcing a full download of the code for the admin panel, settings screen, and payment flow too. This kills the TTI (time to interactive).

The move is simple. **Split the code at screen (route) boundaries, and fetch it with a dynamic `import()` when it becomes necessary.** React supports this as standard with `lazy`. The official definition is this.

```js
const SomeComponent = lazy(load)
```

`load` is a **function that returns a Promise (or a thenable)** whose `.default` must be a valid React component. React calls `load` **only when it first tries to render that component**, and **the result is cached, so `load` runs only once**.

### 1.2 The Minimal Form, Exactly per the Docs

```tsx
import { lazy, Suspense } from "react";

// ✅ 必ずモジュールのトップレベルで宣言する
const MarkdownPreview = lazy(() => import("./MarkdownPreview"));

function Editor() {
  return (
    <Suspense fallback={<Loading />}>
      <h2>Preview</h2>
      <MarkdownPreview />
    </Suspense>
  );
}
```

There's one pitfall the docs **explicitly warn about**.

> **Always declare `lazy` at the module top level.** Declare it inside a component, and on every re-render it's treated as a different thing, **resetting the state.**

```tsx
// 🔴 やってはいけない：再レンダーごとにstateが飛ぶ
function Editor() {
  const MarkdownPreview = lazy(() => import("./MarkdownPreview"));
  // ...
}
```

This bug appears as a hard-to-reproduce defect like "the form occasionally resets," and investigation is hell. **`lazy` fixed at the top level** — make this a discipline with no exceptions.

### 1.3 Combine with a Router: Lazy-Loading 107 Routes

In a real product, the standard is to use `lazy` **at the unit of router definition**. In a B2B SaaS I was involved in (for the lumber industry), I **lazy-loaded all 107 routes** to minimize the initial bundle. Because each route becomes an independent chunk, only the JS for the screens the user steps on comes down in order.

```tsx
import { lazy, Suspense } from "react";
import { createBrowserRouter, RouterProvider } from "react-router";

// 各ルート = 各チャンク。トップレベルで宣言（state リセット回避）
const Dashboard = lazy(() => import("./routes/Dashboard"));
const Invoices = lazy(() => import("./routes/Invoices"));
const Settings = lazy(() => import("./routes/Settings"));

// ルート要素を Suspense でラップする小さなヘルパー（DRY）
function lazyRoute(Component: React.ComponentType) {
  return (
    <Suspense fallback={<RouteSkeleton />}>
      <Component />
    </Suspense>
  );
}

const router = createBrowserRouter([
  { path: "/", element: lazyRoute(Dashboard) },
  { path: "/invoices", element: lazyRoute(Invoices) },
  { path: "/settings", element: lazyRoute(Settings) },
]);

export function App() {
  return <RouterProvider router={router} />;
}
```

Here, I carved out a thin helper called `lazyRoute` to **consolidate the knowledge of "wrap in Suspense" in one place** (DRY). Hand-write 107 `<Suspense>` blocks and swapping the fallback or adding an error boundary becomes hell. With the "shape" of the boundary closed into one function, a change is done in a single point (ETC).

> **A dynamic `import()` becomes chunk splitting as-is** — this is a feature on the bundler side. In Vite (Rollup) and webpack alike, finding the syntax `import("./X")` automatically carves out a separate chunk for X. Vite's official docs also say it "automatically rewrites code-split dynamic import calls into a preload step," and when async chunks share a common chunk, it fetches them in **parallel**. So the classic problem of "split too much and the waterfall grows" is considerably mitigated by modern bundlers.

### 1.4 Decide the "Granularity" of Code Splitting: Route-Level vs. Component-Level

"Where to split" is a trade-off between performance and complexity. Split too much and request count and flicker increase; split too little and the initial bundle gets fat. Here's the judgment criteria in a table.

| Aspect | Split at route level | Split at component level |
| --- | --- | --- |
| Main purpose | Shrink the initial bundle (top priority) | Defer heavy, rarely-used UI |
| Typical target | Each screen / each tab | Rich editor, map, graph, modal, PDF export |
| Effect | Large (initial JS becomes only the screen's worth) | Medium (defer a specific heavy library) |
| Risk | Almost none (screen transitions are async anyway) | Over-splitting causes flicker / waterfall |
| Judgment | **Split all routes first** | **Split only the spots with a heavy dependency** |

My baseline policy is **"split all routes; split components only where there's a heavy dependency."** For example, a Markdown preview, a rich-text editor, a map (Leaflet/Mapbox), a graph (Recharts, etc.) — these pull in hundreds of KB of dependencies, so there's great value in **not loading them until actually opened**. Conversely, individually `lazy`-ifying lightweight components like buttons or cards isn't worth it: the reduction gained < the cost of flicker and complexity (YAGNI).

```tsx
// ✅ 重い依存を持つコンポーネントだけ追加で分割する
const RichTextEditor = lazy(() => import("./RichTextEditor")); // ~300KB
const MapView = lazy(() => import("./MapView")); // 地図ライブラリ込み

function ReviewForm() {
  const [editing, setEditing] = useState(false);
  return (
    <section>
      {editing ? (
        <Suspense fallback={<EditorSkeleton />}>
          <RichTextEditor />
        </Suspense>
      ) : (
        <button type="button" onClick={() => setEditing(true)}>
          レビューを書く
        </button>
      )}
    </section>
  );
}
```

The editor's JS doesn't come down until you press "Write a review." This is the essence of deferral — not deleting code, but **putting it off until the moment it becomes necessary**.

---

## 2. Place `Suspense` Correctly: Designing the Loading UX

`lazy` only becomes meaningful paired with `Suspense`. How you place `Suspense` itself determines the quality of the user experience.

### 2.1 The Exact Spec of `Suspense`

According to the official docs, `<Suspense>` has only two props.

- **`children`**: the UI you actually want to render. When this **suspends** during rendering, the boundary switches to `fallback`.
- **`fallback`**: an alternative UI until loading completes. A lightweight placeholder such as a spinner or skeleton.

And the **triggers that activate Suspense** are explicitly enumerated by the docs.

> What activates Suspense is **only a Suspense-enabled data source**:
> - **Data fetching with Suspense-enabled frameworks** like Relay or Next.js
> - **Lazy-loading component code with `lazy`**
> - **Reading the value of a cached Promise with `use`**
>
> **Suspense does NOT detect data fetched inside an Effect or an event handler.**

That is, the conventional pattern "`fetch` inside `useEffect` and `setState`" can't be caught by Suspense. In this article's scope, suspending via `lazy` (code splitting) is the star.

### 2.2 "Show All at Once" or "Show in Order"

The most important point about `Suspense`'s behavior is that **how it looks changes with how you place the boundary**. Quoting the official behavior.

> By default, the **entire tree inside Suspense is treated as a single unit**. Even if just one component suspends, **all of them are replaced by the fallback together**, and they're displayed **all at once** after everyone is ready.

```tsx
// この中身は「全員揃ってから一斉表示」
<Suspense fallback={<Loading />}>
  <Biography />
  <Panel>
    <Albums />
  </Panel>
</Suspense>
```

To show things progressively, **nest** the `Suspense`.

```tsx
// BigSpinner → Biography 表示 → AlbumsGlimmer → Albums 表示、の順
<Suspense fallback={<BigSpinner />}>
  <Biography />
  <Suspense fallback={<AlbumsGlimmer />}>
    <Panel>
      <Albums />
    </Panel>
  </Suspense>
</Suspense>
```

The design judgment is this. **If "the page skeleton immediately, the heavy parts later," split the boundaries. If "you don't want to show a half-baked state (showing only part of a form is a problem)," consolidate into one boundary.** Draw the boundary by working backward from the UX requirements — the number and position of `Suspense` is the progressivity of the look itself.

### 2.3 Don't Hide What's Already Visible: `startTransition`

A common accident is **the whole screen reverting to the fallback and flickering on every navigation**. While fetching the new route's chunk, Suspense hides the existing screen and shows a spinner.

What prevents this is `startTransition`. The docs say:

> Marking a state update as **non-urgent** with `startTransition` **prevents Suspense from hiding already-visible content**. During the transition, React **waits until enough data is loaded to avoid the unwanted appearance of a fallback** — though a newly rendered Suspense boundary can still show its fallback immediately.

```tsx
import { useTransition } from "react";

function Nav() {
  const [isPending, startTransition] = useTransition();

  function navigate(url: string) {
    startTransition(() => {
      // 遷移中も「いま見えている画面」は隠れない
      setPage(url);
    });
  }

  return (
    <nav aria-busy={isPending}>
      {/* isPending を使って控えめなインジケータを出す */}
    </nav>
  );
}
```

Passing `isPending` to `aria-busy` is foreshadowing — it's to **also tell the screen reader "loading now"** — and this connects to the a11y of chapter 5. A by-product of "fast" design becomes "usable" design as-is, a fine example.

---

## 3. React Compiler: Automatically Eliminate Unnecessary Re-renders

Even if you shrink the initial bundle, it's meaningless if it's **heavy on every interaction**. React 19 automatically crushes enemy ② ("unnecessary re-renders") with the **compiler**.

### 3.1 What Does It Do for You?

The official definition is this.

> React Compiler **automatically optimizes your app at build time**. It **replaces manual `useMemo` / `useCallback` / `React.memo()` with automatic equivalents**, so **you don't need to rewrite your code**.

The optimization is roughly two classes.

1. **Skip cascading re-renders of components**: even if the parent re-renders, a child whose props haven't effectively changed isn't re-rendered.
2. **Skip high-cost computations outside components**: automatically memoize heavy computations.

In short, the **"cognitive cost of memoization"** — pasting `useMemo` / `useCallback` / `React.memo` by hand all over the place — disappears. In a large frontend, the gaps and excesses of this manual work were precisely the breeding ground of re-render problems, so the effect is large.

### 3.2 A Concrete Example of "Manual memo Becomes Unnecessary"

Before (a world of pasting manual memoization):

```tsx
// 手で useMemo / useCallback / memo を貼らないと子が無駄に再描画する
const RestaurantList = React.memo(function RestaurantList({
  items,
  onSelect,
}: Props) {
  const sorted = useMemo(
    () => [...items].sort((a, b) => b.rating - a.rating),
    [items],
  );
  const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);
  return <List items={sorted} onSelect={handleSelect} />;
});
```

After (assuming React Compiler):

```tsx
// コンパイラが自動でメモ化する。memo / useMemo / useCallback は不要
function RestaurantList({ items, onSelect }: Props) {
  const sorted = [...items].sort((a, b) => b.rating - a.rating);
  return <List items={sorted} onSelect={(id) => onSelect(id)} />;
}
```

The After is **more straightforward, more readable, and prone to neither memoization gaps nor excessive memoization**. This is a change directly tied to the "long-term value of code." Manual memoization was also a **breeding ground for bugs** via dependency-array mismatches. That this structurally disappears is huge.

### 3.3 The Limits and Premise of the Effect: "Compliance with the Rules of React" Is the Condition

Misunderstand this and you'll get hurt, so I'll write it clearly. **React Compiler is magic that works on the premise that "you're following the Rules of React."** Quoting the official words.

> The compiler **understands the Rules of React**, so you don't need to rewrite your code. (...) **Whether you can deploy to production depends on the health of your codebase and how well you follow the Rules of React.**

That is, code that violates the rules — destructively mutating props or state during render, causing side effects during render — **the compiler can't safely optimize** (worst case, the behavior changes). So as a premise —

- **Install `eslint-plugin-react-hooks` (the linter for the Rules of React) and crush violations.**
- **You can keep `useMemo` / `useCallback` as "escape hatches."** The docs also state "you may leave existing memoization as-is (since removing it can change the compiled output, test carefully if you remove it)."

### 3.4 Opt Out Partially: `"use no memo"` / `"use memo"`

For spots the compiler should exclude, or you want to temporarily exclude, control it with **directives**. The exact string literals the docs define are two.

- **`"use memo"`** — **include** that function (or file) in compilation
- **`"use no memo"`** — **exclude** it from compilation

```tsx
// 関数単位で外す（デバッグ・非互換コードの隔離）
function LegacyChart() {
  "use no memo"; // ← 関数本体の先頭に置く
  // コンパイラが扱えない古い実装をここに隔離
  return <ThirdPartyChart />;
}
```

```tsx
// ファイル単位でオプトインする（全importより前、ファイル先頭）
"use memo";

function Component1() {
  return <div>Compiled</div>;
}
```

The docs' **important caution**: a directive **must be placed as a string literal at the top of a function body or the top of a module**, and if the position is off it's **ignored**. And `"use no memo"` **should always leave a comment for "why it was excluded" and be a temporary measure with a tracking issue** — the very discipline of not silently burying technical debt. When I find a `"use no memo"`, I always write a reason comment and a planned fix as a set.

> **Compile modes**: `annotation` (only those with `"use memo"`) / `infer` (the compiler decides, overridable by directives) / `all` (everything, excludable with `"use no memo"`). **When retrofitting an existing large codebase, start from a part** is safe (incremental adoption). Apply it all at once and rule violations surface, and isolating the cause gets hard.

---

## 4. Bundle Optimization: Design Chunks with `manualChunks`

We split routes with `lazy`. What remains is **how to bundle the "common dependencies (vendor)" into chunks**. Leave this alone and the same big library group rides separately whichever route you open, or cache efficiency degrades. Control it with Vite (Rollup)'s `manualChunks`.

### 4.1 The Exact Spec of `output.manualChunks`

According to Rollup's docs, `output.manualChunks` takes **two forms**.

- **Object form** `{ [chunkAlias: string]: string[] }`: the key is the chunk name, the value is **the array of modules to put in that chunk**.
- **Function form** `(id: string) => string | void`: receives a resolved module ID and **bundles that module and its dependencies into the chunk of the returned string name**. Returning `void` leaves it to automatic splitting.

```ts
// vite.config.ts — オブジェクト形式（公式の最小例に忠実）
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // lodash 配下を deep import しても1つの vendor チャンクにまとまる
          lodash: ["lodash"],
        },
      },
    },
  },
});
```

### 4.2 Function Form: Bundle node_modules into Meaningful Units

In practice the **function form** is handy. The official boilerplate is "bundle `node_modules` into vendor."

```ts
// vite.config.ts — 関数形式
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string) {
          if (id.includes("node_modules")) {
            return "vendor"; // 依存をまとめて1チャンクに
          }
          // それ以外は Rollup の自動分割に委ねる
        },
      },
    },
  },
});
```

However, **"all into one vendor"** tends to be overdoing it. Heavy libraries (maps, graphs, editors, animation) are more effective carved into separate chunks so that **only the routes using them load them**.

```ts
manualChunks(id: string) {
  if (id.includes("node_modules")) {
    // 重く・特定画面でしか使わない依存は独立チャンクに
    if (id.includes("framer-motion")) return "vendor-motion";
    if (id.includes("recharts") || id.includes("d3")) return "vendor-charts";
    // 安定して全画面で使う土台はまとめてキャッシュ効率を上げる
    if (id.includes("react") || id.includes("scheduler")) return "vendor-react";
    return "vendor"; // 残りの雑多な依存
  }
}
```

There are only two **design guidelines**.

1. **A foundation used stably across all screens** (React itself, etc.) → bundle into one chunk → its content rarely changes, so **the browser cache stays effective for a long time**.
2. **A heavy dependency used only on specific screens** → carve into an independent chunk → only those who step on that route pay for it.

> **Beware over-splitting**: split chunks too finely and the HTTP request count and metadata overhead increase, which can make things slower. **Measure first, then split** — visualize the bundle with `vite build`'s output (the per-chunk size list) or `rollup-plugin-visualizer`, and **isolate only the actually-fat dependencies** on target. That's the iron rule (measurement, not guesswork. YAGNI).

---

## 5. Accessibility: Build It as "Design," the Same as Speed

From here is enemy ③. And as I wrote at the start, **a11y is contiguous with performance**. Choose the right semantics and the code shrinks, SEO rises, and anyone can use it. Not a bolt-on "support," but design from the start.

### 5.1 First Principle: "No ARIA Is Better Than Bad ARIA"

Let's start with the most important warning MDN places at the top.

> **"No ARIA is better than bad ARIA."** In WebAIM's million-page study, **homepages with ARIA present had on average 41% more detected errors than those without**. ARIA is a technology meant to enhance accessibility, but misused, the harm can outweigh the benefit.

And the **first rule of ARIA**:

> **If a native HTML element or attribute already has the semantics and behavior you need, use that native element rather than repurposing an element and adding an ARIA role/state/property.**

A concrete example. Rather than building a progress bar yourself with a `role="progressbar"` `<div>`, using `<progress>` is overwhelmingly more correct.

```tsx
// 🔴 自作 progressbar：role/value/min/max を全部自分で管理する羽目に
<div role="progressbar" aria-valuenow={75} aria-valuemin={0} aria-valuemax={100} />

// ✅ ネイティブ：キーボード・読み上げ・状態管理が最初から組み込み
<progress value={75} max={100}>75%</progress>
```

As MDN drives home, native elements like `<input>` and `<button>` have **keyboard accessibility, role, and state built in**. "Disguise" these with a `<div>` + ARIA and you take on **the responsibility of reproducing all that behavior in script yourself**. So — **semantic HTML first. ARIA is the minimal complement for when native can't express it.**

### 5.2 The Decision Table for "Use / Don't Use" ARIA

During implementation, you make this decision every time. Tabling it removes the hesitation.

| What you want to do | First, with native | If native can't | 
| --- | --- | --- |
| A clickable operation | `<button type="button">` | (native suffices, basically) |
| In-page link / navigation | `<a href>` | — |
| Heading / sectioning | `<h1>`–`<h6>` / `<section>` / `<nav>` / `<main>` | — |
| Form input and label | `<label>` + `<input>` | `aria-label` when there's no visual label |
| Progress / range | `<progress>` / `<input type="range">` | `role` + `aria-valuenow`, etc. if custom |
| Open/close toggle | `<details>` / `<summary>` | `aria-expanded` if custom |
| Announcing a dynamic update (read aloud) | (no native counterpart) | `aria-live` region |
| Exclude decorative elements from reading | (no counterpart) | `aria-hidden="true"` |

The judgment axis fits in one line. **"Is there a native HTML element with the same meaning? If so, use it. Complement with ARIA only when there isn't."** When your hand reaches for the right column (ARIA) of the table, build the habit of pausing a beat to check the left column.

### 5.3 The Necessary and Sufficient ARIA Attributes

Use the attributes MDN lists only for the gaps native can't fill.

- **`aria-label`**: gives an accessible name directly (icon-only buttons, etc., when there's no visible text).
- **`aria-labelledby`**: references another element as "this element's label."
- **`aria-describedby`**: references another element as "this element's description" (linking an error message, etc.).
- **`aria-live`**: a live region. **Has assistive tech read aloud dynamically changing content** (a toast, a search-result count, etc.).
- **`aria-hidden="true"`**: hides a decorative element from the accessibility tree.

```tsx
// アイコンのみのボタン：可視テキストが無いので aria-label で名前を与える
<button type="button" aria-label="お気に入りに追加">
  <HeartIcon aria-hidden="true" /> {/* 装飾アイコンは読み上げ対象外に */}
</button>

// 検索結果件数：動的更新を読み上げさせる（aria-live）
<p aria-live="polite">{count} 件の店舗が見つかりました</p>

// 入力エラーを説明として紐付ける（aria-describedby）
<input id="email" aria-describedby="email-error" />
<p id="email-error" role="alert">メールアドレスの形式が正しくありません</p>
```

Add `aria-hidden="true"` to `HeartIcon` and put `aria-label` on the button side — this way the "heart (decoration)" isn't read aloud, and only "Add to favorites (meaning)" is conveyed. **Separating decoration from meaning** is the trick.

### 5.4 Keyboard Operation and Focus Management

For users who can't or don't use a mouse, **all operations must be completable by keyboard**. Native elements help here too. `<button>` / `<a href>` / `<input>` are **focusable by default and fire on Enter/Space**. So just by "avoiding `<div onClick>` and using native," most of keyboard support comes free.

For scenes where native isn't enough (custom widgets, modals), you write `tabindex` and focus control yourself.

```tsx
// モーダル：開いたら中へフォーカスを移し、Escで閉じ、閉じたら呼び出し元へ返す
function Modal({ open, onClose, children }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const openerRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!open) return;
    openerRef.current = document.activeElement as HTMLElement;
    dialogRef.current?.focus(); // 開いたら中へフォーカス移動
    return () => openerRef.current?.focus(); // 閉じたら元の要素へ返す
  }, [open]);

  if (!open) return null;
  return (
    <div
      ref={dialogRef}
      role="dialog"
      aria-modal="true"
      tabIndex={-1} // プログラムからフォーカス可能にする（Tab順には入れない）
      onKeyDown={(e) => {
        if (e.key === "Escape") onClose();
      }}
    >
      {children}
    </div>
  );
}
```

`tabIndex={-1}` means "**not in the Tab cycle, but programmatically focusable with `.focus()`**" (`tabindex="0"` would put it in the cycle). In a real product, follow the **dialog pattern** of the ARIA Authoring Practices (W3C's APG) and implement up to a focus trap (Tab doesn't escape to the background). When building a complex widget yourself, **always reference the relevant APG pattern** — reinventing the wheel is a source of accidents.

### 5.5 Care for Motion: `prefers-reduced-motion` (in the Swipe-UI Context)

This is the heart of my restaurant-matching project. I implemented a **Tinder-style swipe UI in React + Framer Motion**, but flashy animations can **cause dizziness or nausea** for users with vestibular disorders. MDN's `prefers-reduced-motion` is a media feature that detects users who chose "reduce motion" in OS settings.

> The values are **`no-preference`** and **`reduce`**. `@media (prefers-reduced-motion)` is equivalent to `@media (prefers-reduced-motion: reduce)`.

In CSS, suppress animation when the reduce setting is on.

```css
/* 既定はアニメーションあり。"減らす"設定のときだけ抑制する */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
```

A **JS animation** like Framer Motion reads the same setting with `matchMedia` and swaps out the animation content itself.

```tsx
// 動きを減らす設定なら、スワイプの大きな移動アニメを「即時切り替え」に落とす
function usePrefersReducedMotion(): boolean {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

function SwipeCard({ restaurant }: { restaurant: Restaurant }) {
  const reduced = usePrefersReducedMotion();
  return (
    <motion.div
      // 減らす設定なら派手なバネ運動を消し、一瞬で確定させる
      transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 300 }}
      drag={reduced ? false : "x"}
    >
      {/* カード内容 */}
    </motion.div>
  );
}
```

**Keep the swipe feature itself, while dropping only the "amount of motion" according to the setting.** Make it not-painful for those it pains, without taking away the feature — this is the correct answer for "care for motion." Hold the perspective that animation is not decoration but **UI that acts on the user's body**.

### 5.6 Checking a11y: Automated + Manual

a11y isn't "done once you write it" — the main thrust is to **make it verifiable** (verification-first).

- **Automated checks**: build `axe` (`@axe-core/playwright`, etc.) into E2E and **reject machine-detectable violations like insufficient contrast, missing labels, and misused roles** in CI. This portfolio site itself runs a WCAG sweep with Playwright + axe.
- **Manual checks**: confirm with human eyes and ears the parts automation can't catch — **can you complete all operations with the Tab key alone / does it make sense in a screen reader (VoiceOver / NVDA) / is the focus ring visible?**

**Automated checks eliminate "obvious mistakes," but "does it make sense" can only be judged by a human.** Run both wheels. Even if axe is green, if it's inoperable by Tab, it's a fail.

---

## 6. Scaling Multilingual Support: Type-Safe i18n

Finally, i18n. In restaurant-matching, I supported four languages (Japanese, English, Chinese, Korean) **with a manual implementation, without an i18n library**. The key to not breaking down "without a library" is **type safety**.

### 6.1 Why "Without a Library" Held Up

At small-to-medium scale, when you don't need plurals, gender, or complex formatting, a heavy i18n library tends to be overkill (YAGNI). **A dictionary object + type constraints** scale plenty. When requirements grow complex (ICU MessageFormat, per-language load splitting, etc.), only then consider a library. **"Two is coincidence; three is finally a pattern"** — the discipline of not rushing abstraction.

### 6.2 Prevent Missing Keys with Types

The biggest risk of a manual implementation is "a translation is missing in just one language." Crush this **at compile time**.

```ts
// locales/index.ts — 1つの言語を「正」とし、他言語を同じ形に強制する
const ja = {
  "home.title": "近くの美味しいお店を見つけよう",
  "card.like": "いいね",
  "card.skip": "スキップ",
} as const;

// 基準となるキー集合の型（ja から導出 → 単一の真実の源 = DRY）
export type MessageKey = keyof typeof ja;
type Dictionary = Record<MessageKey, string>;

// ✅ en/zh/ko は Dictionary 型なので、キーが1つでも欠けると型エラー
const en: Dictionary = {
  "home.title": "Find great restaurants near you",
  "card.like": "Like",
  "card.skip": "Skip",
};
const zh: Dictionary = {
  "home.title": "发现附近的美食",
  "card.like": "喜欢",
  "card.skip": "跳过",
};
const ko: Dictionary = {
  "home.title": "근처 맛집을 찾아보세요",
  "card.like": "좋아요",
  "card.skip": "건너뛰기",
};

export const locales = { ja, en, zh, ko } as const;
export type Locale = keyof typeof locales;
```

Fix `ja` with `as const` and derive `MessageKey` from it. Constrain `en`/`zh`/`ko` with the `Dictionary` type, and **the moment you forget to translate even one key, `tsc` fails**. "Missing translations," the most common i18n bug, disappears **at build time, not runtime**. This is the power of type safety (no `any` at all; harden the boundary with types).

### 6.3 Type-Safe Translation Retrieval Too

```ts
// t() のキーも MessageKey に縛る → 存在しないキーは補完されず、書けばエラー
export function createTranslator(locale: Locale) {
  const dict = locales[locale];
  return (key: MessageKey): string => dict[key];
}
```

```tsx
// 使う側。キー名は IDE 補完が効き、タイポは即エラーになる
function Card({ locale }: { locale: Locale }) {
  const t = createTranslator(locale);
  return (
    <article lang={locale}> {/* lang 属性で言語を明示 = a11y にも効く */}
      <h2>{t("home.title")}</h2>
      <button type="button">{t("card.like")}</button>
      <button type="button">{t("card.skip")}</button>
    </article>
  );
}
```

Here, the **`lang` attribute** of `<article lang={locale}>` is important. This is the **point of contact with a11y** — a screen reader looks at `lang` and **reads aloud with the correct language's speech engine**. Without `lang`, it would read Korean with Japanese pronunciation. **i18n and a11y are contiguous**, and the `lang` attribute is their nexus.

### 6.4 Split Loading at Scale

As languages increase and dictionaries get fat, **split the language files with `lazy` / dynamic import** — the same tooling as chapter 1 works as-is.

```tsx
// 必要な言語の辞書だけを動的に取りに行く（初期バンドルに全言語を載せない）
async function loadLocale(locale: Locale): Promise<Dictionary> {
  switch (locale) {
    case "ja": return (await import("./locales/ja")).default;
    case "en": return (await import("./locales/en")).default;
    case "zh": return (await import("./locales/zh")).default;
    case "ko": return (await import("./locales/ko")).default;
  }
}
```

**The technique of code splitting becomes the scaling strategy for i18n as-is.** The same principle as chapter 1's "don't send it until it's needed." Bundling all four languages is fine, but at 10 languages and big dictionaries, splitting starts to pay off (this, too, after measuring).

---

## 7. Summary: A Cheat Sheet for Fast, Usable, Multilingual

Finally, a quick reference for when you're stuck.

**① Shrink the initial bundle (speed)**
- Split all routes with **`lazy` + `Suspense`**. **Always declare `lazy` at the top level** (state-reset avoidance).
- Component-level splitting **only where there's a heavy dependency** (editor, map, graph).
- **How you place `Suspense` = how it looks.** One boundary to show all at once, nesting for progressive.
- If the screen flickers on transition, **don't hide existing UI with `startTransition` / `useTransition`**.

**② Eliminate re-renders (speed)**
- Make manual `useMemo` / `useCallback` / `React.memo` unnecessary with **React Compiler**.
- The premise is **compliance with the Rules of React**. Crush violations with `eslint-plugin-react-hooks`.
- Opt out only where needed with **`"use no memo"`** (a temporary measure with a reason comment + tracking issue).

**③ Make it accessible (usable)**
- **No ARIA is better than bad ARIA.** Semantic HTML first; ARIA is a minimal complement.
- Icon buttons get `aria-label`, decoration gets `aria-hidden`, dynamic updates get `aria-live`.
- **Complete all operations by keyboard.** Modals: move focus, Esc, return to the caller.
- Suppress motion with **`prefers-reduced-motion`** (don't take away the feature, just drop the amount of motion).
- Verify with **both wheels: axe (automated) + Tab/screen reader (manual)**.

**④ Scale multilingual support (usable)**
- Small-to-medium scale is fine without a library. Detect missing translations at build time with **type safety that treats one language as canonical and constrains the others to the same shape**.
- Make the reading-aloud language explicit with the **`lang` attribute** (the nexus of i18n × a11y).
- When dictionaries get fat, **split with dynamic import** — the same tool as code splitting.

---

"Fast" and "usable" are not separate jobs. **Choose the right semantics, and send only what's needed when it's needed** — this single design decision improves both performance and accessibility at the same time. The moment you relegate a11y to "something to support later," the code gets fat, SEO drops, and someone can't use it. Weaving it into the design from the start is, in the end, the fastest and cheapest.

I implemented, for a restaurant-matching site for foreign tourists, a **Framer Motion swipe UI in four languages, accessibly**, and in another B2B SaaS scaled the frontend with **107 lazy-loaded routes** and the type-safety discipline of **no `any` and Zod boundary validation**. Building **fast, alone**, with generative AI (Claude Code) as a partner, while not removing the **human verification gates** of a11y and type safety — that's how I work.

**"I want to build a large-scale, fast, usable-by-anyone, multilingual React frontend" — I can accompany you end-to-end, from design through implementation and measurement.** Even from the requirements-organizing stage, feel free to reach out.

---

### References (Official Documentation)

- [React `lazy`](https://react.dev/reference/react/lazy) — the spec of `lazy(load)`, the mandatory top-level declaration rule, Suspense integration
- [React `<Suspense>`](https://react.dev/reference/react/Suspense) — props (children / fallback), trigger conditions, progressive display, startTransition
- [Learn React Compiler](https://react.dev/learn/react-compiler) — auto-memoization, the Rules of React premise, incremental adoption
- [React Compiler Directives](https://react.dev/reference/react-compiler/directives) — the exact behavior and placement of `"use memo"` / `"use no memo"`
- [Rollup `output.manualChunks`](https://rollupjs.org/configuration-options/#output-manualchunks) — chunk control with object form / function form
- [Vite Features](https://vite.dev/guide/features) — dynamic-import chunk splitting, auto preload, glob import
- [MDN: ARIA](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA) — No ARIA is better than bad ARIA, the first rule, the main attributes
- [W3C: ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) — keyboard/focus patterns for dialogs, etc.
- [MDN: `prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) — values (no-preference / reduce), CSS and matchMedia
