Concurrent Mode機能:startTransition・useDeferredValue
React 18で正式導入されたConcurrent Mode。なぜ必要なのか、startTransitionとuseDeferredValueが内部でどう動くのかを徹底解説します。
🔥 問題提起:ユーザー入力がブロックされる
検索ボックスに文字を入力するたびに10,000件のリストをフィルタリングする、よくあるUIを考えてみましょう。 React 17以前では、1文字打つたびにリスト全体の再レンダリングが同期的に走り、入力が遅れて感じる問題が発生していました。
問題のあるコード(React 17以前)
function SearchPage() {
const [query, setQuery] = useState('');
// queryが変わるたびに10,000件をフィルタリング
// この処理が50msかかるとする
const filteredItems = items.filter(item =>
item.name.includes(query)
);
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)} // ← ここでブロック
/>
<ItemList items={filteredItems} /> {/* ← 重い */}
</>
);
} - 入力のたびに同期レンダリングが走る
- 10,000件のフィルタリング中はメインスレッドがブロック
- ユーザーはキーを押してから50ms後に文字が現れる
- タイピングがカクカク・ラグが発生する
なぜ起きるのか?
React 17以前のデフォルト動作はすべて「同期レンダリング」です。
setStateが呼ばれると、
Reactはすべての更新を1つのバッチとして処理し終えるまでブラウザに制御を返しません。
これはStack Reconcilerの時代から変わらない特性でした。
🎯 結論から言う
Concurrent Modeは「レンダリングに優先度をつける」仕組みです。
緊急度の高い更新(入力)を優先し、重い更新(リスト再レンダリング)を後回しにできます。
テキスト入力、クリック、キー押下など
即座に反映されるべきインタラクション
検索結果の更新、タブ切り替えなど
少し遅れても許容されるUI変化
🗺️ FiberとLane:優先度の仕組み
Concurrent Modeの核心はLaneという優先度システムです。 各更新はビットマスクで表現されたLaneに割り当てられ、Reactのスケジューラーが高優先度のLaneを先に処理します。
// packages/react-reconciler/src/ReactFiberLane.js(簡略) export const SyncLane = 0b0000000000000000000000000000001; // 最高 export const InputContinuousLane = 0b0000000000000000000000000000100; export const DefaultLane = 0b0000000000000000000000000010000; export const TransitionLane1 = 0b0000000000000000000000001000000; // startTransition export const IdleLane = 0b0100000000000000000000000000000; // 最低
TransitionLaneでリスト再レンダリング開始
startTransitionでマークされた更新がwork-in-progress treeを構築し始める
ユーザーが新しい文字を入力(SyncLane)
高優先度の更新が来たことをスケジューラーが検知
Transition処理を中断、入力更新を先に実行
work-in-progress treeを破棄(または保留)し、入力をcommit
Transition処理を新しいstateで最初からやり直し
古い中途半端なツリーは使わず、最新stateで再計算
⚠️ 重要な点
Concurrent Modeでは、レンダリングが本当に並列で実行されるわけではありません。 JavaScriptはシングルスレッドです。「割り込み可能」とは、Reactがフレームごとに作業を小刻みに分割し、 ブラウザのアイドル時間に少しずつ処理するという意味です。
🚀 startTransition:低優先度更新のマーキング
startTransitionは、その中のsetState呼び出しを
「Transition(非緊急)」としてマークするAPIです。
import { useState, startTransition } from 'react';
function SearchPage() {
const [inputValue, setInputValue] = useState(''); // 緊急
const [searchQuery, setSearchQuery] = useState(''); // 非緊急
function handleChange(e) {
// 入力値の更新は緊急(即座に反映)
setInputValue(e.target.value);
// 検索結果の更新は非緊急(遅れてもOK)
startTransition(() => {
setSearchQuery(e.target.value);
});
}
return (
<>
<input value={inputValue} onChange={handleChange} />
{/* searchQueryが変わったときだけリストが重い処理をする */}
<SearchResults query={searchQuery} />
</>
);
} // packages/react/src/ReactStartTransition.js(概略)
export function startTransition(scope) {
const prevTransition = ReactCurrentBatchConfig.transition;
// トランジションフラグを立てる
ReactCurrentBatchConfig.transition = {};
try {
scope(); // この中のsetStateはTransitionLaneに割り当てられる
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
}
}
// scheduleUpdateOnFiber内でLane決定
function requestUpdateLane(fiber) {
if (isInsideTransition()) {
return claimNextTransitionLane(); // TransitionLane1~15のいずれか
}
// ... 通常はSyncLaneかDefaultLane
} ✅ startTransitionが適している場面
- 検索クエリに対するフィルタリング結果の表示
- タブ切り替えでの重いコンテンツレンダリング
- フォーム入力値をもとにしたプレビュー更新
- ページネーションのページ変更
❌ startTransitionが不適切な場面
- テキスト入力自体のstate(inputValue)→ 緊急で更新すべき
- フォーム送信後の成功/失敗状態 → ユーザーが待っている
- アニメーション制御 → タイミングが重要
⏳ useTransition:isPendingで読み込み中を表示
useTransitionはstartTransitionと
isPendingフラグのセットを提供するHookです。
Transition処理中であることをUIに反映できます。
import { useState, useTransition } from 'react';
function SearchPage() {
const [isPending, startTransition] = useTransition();
const [inputValue, setInputValue] = useState('');
const [searchQuery, setSearchQuery] = useState('');
function handleChange(e) {
setInputValue(e.target.value);
startTransition(() => {
setSearchQuery(e.target.value);
});
}
return (
<>
<input value={inputValue} onChange={handleChange} />
{/* isPendingはTransition処理中にtrueになる */}
{isPending && (
<div class="loading-spinner">検索中...</div>
)}
<SearchResults query={searchQuery} />
</>
);
} isPendingがtrueになるタイミング
startTransition内のsetStateが呼ばれた瞬間から、そのTransition処理がcommitされるまでの間、
isPendingはtrueです。
ユーザーが高速タイピングしている間は、新しいTransitionが次々と来るため、
最後の入力後のレンダリングが完了するまでisPendingがtrueのままになります。
🔁 useDeferredValue:値の遅延バージョンを返す
useDeferredValueは、props経由で受け取った値や
制御できないstateに対して「遅延バージョン」を返すHookです。
startTransitionが「setState呼び出しに優先度をつける」のに対して、
useDeferredValueは「値そのものに遅延を適用する」という違いがあります。
import { useState, useDeferredValue, memo } from 'react';
// memoで囲むことで、deferredQueryが変わったときだけ再レンダリング
const SearchResults = memo(function SearchResults({ query }) {
// 重いフィルタリング処理
const results = heavyFilter(allItems, query);
return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
});
function SearchPage() {
const [query, setQuery] = useState('');
// deferredQueryはqueryの「遅延バージョン」
// 新しいqueryが来ても、deferredQueryは前の値を保持し続ける
const deferredQuery = useDeferredValue(query);
// queryとdeferredQueryが違う = 遅延中
const isStale = query !== deferredQuery;
return (
<>
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
{/* deferredQueryが変わったときだけSearchResultsを更新 */}
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<SearchResults query={deferredQuery} />
</div>
</>
);
} // useDeferredValueは内部的にこのようなことをしている
function useDeferredValue(value) {
const [prevValue, setPrevValue] = useState(value);
useEffect(() => {
// valueが変わったら、非緊急(Transition)として内部stateを更新
startTransition(() => {
setPrevValue(value);
});
}, [value]);
return prevValue; // 前の値(遅延バージョン)を返す
}
// ※実際の実装はより最適化されていますが、概念はこの通りです 🧩 実装パターン:実際にどう使うか
パターン1:タブ切り替えの最適化
function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('home');
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<>
<TabButton
isActive={tab === 'home'}
onClick={() => selectTab('home')}
>
ホーム
</TabButton>
<TabButton
isActive={tab === 'profile'}
onClick={() => selectTab('profile')}
>
プロフィール
</TabButton>
{/* isPendingで読み込み中を薄く表示 */}
<Suspense fallback={<Spinner />}>
<div style={{ opacity: isPending ? 0.8 : 1 }}>
{tab === 'home' && <HomePage />}
{tab === 'profile' && <ProfilePage />}
</div>
</Suspense>
</>
);
} パターン2:仮想スクロールとの組み合わせ
function VirtualList({ items }) {
const [filter, setFilter] = useState('');
const deferredFilter = useDeferredValue(filter);
// deferredFilterが変わったときだけ重い計算が走る
const visibleItems = useMemo(() =>
items.filter(item => item.includes(deferredFilter)),
[items, deferredFilter]
);
const isFiltering = filter !== deferredFilter;
return (
<div>
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="フィルター..."
/>
<div style={{
opacity: isFiltering ? 0.6 : 1,
transition: 'opacity 0.2s'
}}>
{visibleItems.map(item => (
<div key={item}>{item}</div>
))}
</div>
</div>
);
} ⚠️ パフォーマンスの注意点
useDeferredValueはmemoやuseMemoと組み合わせることで真の効果を発揮します。
ラップしていないコンポーネントは、deferredQueryが変わるたびに再レンダリングされるため意味がありません。
// ❌ memoなしでは効果なし
<SearchResults query={deferredQuery} />
// ✅ memoで囲むことで遅延が意味を持つ
const SearchResults = memo(function SearchResults({ query }) {
// ...
}); 📌 まとめ
- ✓ Concurrent Modeは同期レンダリングのブロッキング問題を解決するアーキテクチャ
- ✓ FiberのLaneシステムで各更新に優先度ビットマスクが割り当てられる
- ✓ startTransitionはsetStateをTransitionLaneにマークし、割り込みを可能にする
- ✓ useTransitionはisPendingでTransition処理中の状態をUIに反映できる
- ✓ useDeferredValueはprops経由の値に遅延バージョンを返す(内部でstartTransition使用)
- ✓ useDeferredValueの効果を得るにはmemo・useMemoとの組み合わせが必須
- ✓ 実際は「並列処理」ではなく「割り込み可能な逐次処理」がConcurrentの実体