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