React 並行レンダリング実践:useTransition/useDeferredValue/Suspense の正しい使い方

前端工程(更新: 2026年6月2日)

並行レンダリング:React の割り込み可能レンダリング

React 18 以前は同期的レンダリングでした——一度開始すると中断できず、長いタスクが UI インタラクションをブロックしていました。

同期的レンダリング:
  ユーザー入力 → [====== 長いレンダリング ======] → UI 更新
                        ↑ ユーザーが遅延を感じる

並行レンダリング:
  ユーザー入力 → [== レンダリング ==] → 中断 → 入力を処理 → [== レンダリング続行 ==]
                        ↑ 高優先度タスクが低優先度を中断可能

useTransition:低優先度更新のマーキング

基本的な使い方

import { useTransition, useState } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
    // 高優先度:入力欄を即時更新
    setQuery(e.target.value);

    // 低優先度:検索結果は遅延可能
    startTransition(() => {
      setResults(filterLargeList(e.target.value));
    });
  }

  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending && <Spinner />}
      <ResultList results={results} />
    </div>
  );
}

実践:大規模リストフィルタリング

function ProductFilter({ products }: { products: Product[] }) {
  const [filterText, setFilterText] = useState('');
  const [filteredProducts, setFilteredProducts] = useState(products);
  const [isPending, startTransition] = useTransition();

  function handleFilterChange(text: string) {
    setFilterText(text);

    startTransition(() => {
      // 10,000 商品のフィルタリング——低優先度
      const filtered = products.filter(p =>
        p.name.toLowerCase().includes(text.toLowerCase()) ||
        p.category.toLowerCase().includes(text.toLowerCase()) ||
        p.tags.some(t => t.includes(text))
      );
      setFilteredProducts(filtered);
    });
  }

  return (
    <div>
      <SearchInput
        value={filterText}
        onChange={handleFilterChange}
        // isPending 時も入力の応答性を低下させない
      />
      <div style={{ opacity: isPending ? 0.7 : 1, transition: 'opacity 0.2s' }}>
        <ProductGrid products={filteredProducts} />
      </div>
    </div>
  );
}

isPending の正しい使い方

// ❌ isPending で全画面ローディングを表示(ちらつき)
{isPending ? <FullPageSpinner /> : <Content />}

// ✅ isPending で微妙な視覚的フィードバック
<div style={{
  opacity: isPending ? 0.7 : 1,
  transition: 'opacity 0.15s',
  pointerEvents: isPending ? 'none' : 'auto',
}}>
  <Content />
</div>

useDeferredValue:遅延値

useTransition との関係

// useTransition バージョン
const [isPending, startTransition] = useTransition();
function handleChange(value) {
  setQuery(value);
  startTransition(() => setDeferredQuery(value));
}

// useDeferredValue バージョン(同等、より簡潔)
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);

実践:検索サジェスト

function SearchWithSuggestions() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  return (
    <div>
      <SearchInput value={query} onChange={setQuery} />
      {/* Suggestions は遅延値を使用、入力をブロックしない */}
      <Suggestions query={deferredQuery} />
    </div>
  );
}

function Suggestions({ query }: { query: string }) {
  const [suggestions, setSuggestions] = useState<string[]>([]);

  useEffect(() => {
    if (!query) { setSuggestions([]); return; }
    fetchSuggestions(query).then(setSuggestions);
  }, [query]);

  return (
    <ul>
      {suggestions.map(s => <li key={s}>{s}</li>)}
    </ul>
  );
}

useDeferredValue + memo = パフォーマンスの強力な組み合わせ

const deferredQuery = useDeferredValue(query);

// memo で deferredQuery 変更時のみ再レンダリング
const MemoizedResults = memo(Results);

return (
  <div>
    <SearchInput value={query} onChange={setQuery} />
    <MemoizedResults query={deferredQuery} />
  </div>
);

Suspense:宣言的非同期読み込み

基本:ローディング状態

const resource = fetchData();

function ProfilePage() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <ProfileDetails resource={resource} />
      <Suspense fallback={<PostsSkeleton />}>
        <ProfilePosts resource={resource} />
      </Suspense>
    </Suspense>
  );
}

function ProfileDetails({ resource }) {
  // データ準備完了まで Promise をスロー
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

ネストされた Suspense:ウォーターフォール vs 並列

// ❌ ウォーターフォール:各 Suspense が前の完了を待つ
<Suspense fallback={<Skeleton />}>
  <A />
  <Suspense fallback={<Skeleton />}>
    <B />
    <Suspense fallback={<Skeleton />}>
      <C />
    </Suspense>
  </Suspense>
</Suspense>

// ✅ 並列:A/B/C が同時に読み込み
<Suspense fallback={<Skeleton />}>
  <A />
</Suspense>
<Suspense fallback={<Skeleton />}>
  <B />
</Suspense>
<Suspense fallback={<Skeleton />}>
  <C />
</Suspense>

Suspense + React Router v7

import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

ストリーミング SSR + Suspense

サーバーサイド

// app.tsx
function App() {
  return (
    <Layout>
      <Suspense fallback={<ProductSkeleton />}>
        <ProductList /> {/* 非同期データ */}
      </Suspense>
    </Layout>
  );
}

ストリーミング HTML レスポンス

HTTP レスポンスストリーム:

1. 即座に Layout + ProductSkeleton を送信
   <html><body><nav>...</nav><div class="skeleton">...</div>

2. ProductList データ準備完了後、置換をストリーミング送信
   <template id="suspense-boundary">
     <div class="products">...</div>
   </template>
   <script>
     // スケルトンを実際のコンテンツに置換
     document.getElementById('suspense-boundary').replaceWith(...)
   </script>
   </body></html>

Next.js App Router のストリーミング SSR

// app/products/page.tsx
export default async function ProductsPage() {
  // これはサーバーコンポーネント、自動 Suspense
  const products = await fetchProducts();

  return <ProductGrid products={products} />;
}

// app/layout.tsx
export default function Layout({ children }) {
  return (
    <html>
      <body>
        <Nav />
        <Suspense fallback={<Loading />}>
          {children}
        </Suspense>
      </body>
    </html>
  );
}

useActionState:フォームの並行処理

import { useActionState } from 'react';

async function submitForm(prevState: Result, formData: FormData): Promise<Result> {
  const response = await fetch('/api/submit', {
    method: 'POST',
    body: formData,
  });
  return response.json();
}

function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitForm, {
    status: 'idle',
  });

  return (
    <form action={formAction}>
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit" disabled={isPending}>
        {isPending ? '送信中...' : '送信'}
      </button>
      {state.status === 'error' && <p className="error">{state.message}</p>}
      {state.status === 'success' && <p className="success">送信成功!</p>}
    </form>
  );
}

よくある落とし穴

落とし穴 1:startTransition 内の setState は即時ではない

// ❌ 即時更新を期待しているが、実際は遅延される
startTransition(() => {
  setCount(c => c + 1);
});
console.log(count); // まだ古い値!

// ✅ 即時更新は transition の外に置く
setCount(c => c + 1);

落とし穴 2:Suspense 境界が粗すぎる

// ❌ ページ全体に 1 つの Suspense、どのデータも未準備で全画面ローディング
<Suspense fallback={<FullPageLoading />}>
  <Header />
  <Sidebar />
  <MainContent />
  <Footer />
</Suspense>

// ✅ 細粒度 Suspense、各部分が独立して読み込み
<Header />
<Suspense fallback={<SidebarSkeleton />}>
  <Sidebar />
</Suspense>
<Suspense fallback={<MainSkeleton />}>
  <MainContent />
</Suspense>
<Footer />

落とし穴 3:useTransition の過剰使用

// ❌ すべての更新を transition としてマーク(不要)
startTransition(() => {
  setA(value);
  setB(value);
  setC(value);
});

// ✅ 「待てる」更新だけに transition を使用
setA(value); // 即時
startTransition(() => {
  setB(value); // 遅延可能
  setC(value); // 遅延可能
});

パフォーマンス比較

シナリオ 同期的レンダリング 並行レンダリング
大規模リストフィルタリング(10K件) 入力遅延 200ms 入力スムーズ、リスト 50ms 遅延
ページ遷移 ホワイトスクリーン 1s 待機 スケルトン + ストリーミング充填
並列データ読み込み ウォーターフォール 3s 並列 1s
緊急更新によるレンダリング中断 非対応 自動中断

まとめ

React 並行レンダリングの 3 つのコア API にはそれぞれ役割があります:useTransition は遅延可能な更新をマーク、useDeferredValue は遅延値を作成、Suspense は宣言的非同期読み込み。重要な原則:緊急更新(入力、クリック)は即時処理、非緊急更新(フィルタリング、検索、ナビゲーション)は transition としてマーク。ストリーミング SSR と組み合わせることで、並行レンダリングは React アプリケーションのユーザー体験を質的に飛躍させます。

ブラウザローカルツールを無料で試す →

#React#并发渲染#Suspense#useTransition#流式SSR