⚛️ React Boundary
発展 — ⚡ Concurrent Mode

Concurrent Mode機能:startTransition・useDeferredValue

React 18で正式導入されたConcurrent Mode。なぜ必要なのか、startTransitionuseDeferredValueが内部でどう動くのかを徹底解説します。

🔥 問題提起:ユーザー入力がブロックされる

検索ボックスに文字を入力するたびに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は「レンダリングに優先度をつける」仕組みです。

緊急度の高い更新(入力)を優先し、重い更新(リスト再レンダリング)を後回しにできます。

緊急(Urgent)

テキスト入力、クリック、キー押下など
即座に反映されるべきインタラクション

非緊急(Transition)

検索結果の更新、タブ切り替えなど
少し遅れても許容されるUI変化

🗺️ FiberとLane:優先度の仕組み

Concurrent Modeの核心はLaneという優先度システムです。 各更新はビットマスクで表現されたLaneに割り当てられ、Reactのスケジューラーが高優先度のLaneを先に処理します。

Laneの優先度階層(React内部定数)
// 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; // 最低
レンダリング割り込みの流れ
1

TransitionLaneでリスト再レンダリング開始

startTransitionでマークされた更新がwork-in-progress treeを構築し始める

2

ユーザーが新しい文字を入力(SyncLane)

高優先度の更新が来たことをスケジューラーが検知

3

Transition処理を中断、入力更新を先に実行

work-in-progress treeを破棄(または保留)し、入力をcommit

4

Transition処理を新しいstateで最初からやり直し

古い中途半端なツリーは使わず、最新stateで再計算

⚠️ 重要な点

Concurrent Modeでは、レンダリングが本当に並列で実行されるわけではありません。 JavaScriptはシングルスレッドです。「割り込み可能」とは、Reactがフレームごとに作業を小刻みに分割し、 ブラウザのアイドル時間に少しずつ処理するという意味です。

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

startTransitionは、その中のsetState呼び出しを 「Transition(非緊急)」としてマークするAPIです。

startTransitionの基本的な使い方
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} />
    </>
  );
}
startTransition内部での処理(React ソースコード概略)
// 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で読み込み中を表示

useTransitionstartTransitionisPendingフラグのセットを提供するHookです。 Transition処理中であることをUIに反映できます。

useTransitionの使い方
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されるまでの間、 isPendingtrueです。 ユーザーが高速タイピングしている間は、新しいTransitionが次々と来るため、 最後の入力後のレンダリングが完了するまでisPendingtrueのままになります。

🔁 useDeferredValue:値の遅延バージョンを返す

useDeferredValueは、props経由で受け取った値や 制御できないstateに対して「遅延バージョン」を返すHookです。 startTransitionが「setState呼び出しに優先度をつける」のに対して、 useDeferredValueは「値そのものに遅延を適用する」という違いがあります。

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>
    </>
  );
}
startTransition vs useDeferredValue:使い分け
startTransition
useDeferredValue
制御できる対象
setState呼び出し
値(props含む)
使いどころ
state更新の発生源を自分で制御できる場合
親からpropsで値を受け取る場合
内部の仕組み
TransitionLaneにSetStateを割当
内部でuseStateとstartTransitionを組合せ
useDeferredValueの内部実装イメージ
// 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>
  );
}

⚠️ パフォーマンスの注意点

useDeferredValuememouseMemoと組み合わせることで真の効果を発揮します。 ラップしていないコンポーネントは、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の実体

関連記事