0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Qiita Advent Calender 2025企画】React Hooks を Hackしよう!【Part13: useTransitionをふかぼろう!】

Posted at

useTransitionUI を部分的にバックグラウンドでレンダーするための React フックです。ユーザー操作をブロックせずに、高コストな state 更新を遅延させ、優れた UX を提供します。

const [isPending, startTransition] = useTransition()

1. なぜ useTransition が必要か

1.1 UI のブロッキング問題

React では、state 更新がトリガされると即座に再レンダーが開始されます。しかし、重い処理を伴う再レンダーはユーザー操作をブロックしてしまいます。

function TabContainer() {
  const [tab, setTab] = useState('about');
  
  // ❌ 重いコンポーネントへの切り替えでUIがフリーズ
  return (
    <>
      <button onClick={() => setTab('posts')}>Posts</button>
      {tab === 'posts' && <SlowPostsTab />}  {/* 1000件のリストを描画 */}
    </>
  );
}

問題: 「Posts」ボタンをクリックすると、SlowPostsTab の描画が完了するまで UI がフリーズし、他のボタンをクリックできなくなります。

1.2 useTransition が解決すること

useTransition を使うと、緊急ではない更新をバックグラウンドで処理し、ユーザー操作への即時レスポンスを維持できます。

function TabContainer() {
  const [tab, setTab] = useState('about');
  const [isPending, startTransition] = useTransition();
  
  function selectTab(nextTab) {
    // ✅ トランジションでラップして、UIをブロックしない
    startTransition(() => {
      setTab(nextTab);
    });
  }
  
  return (
    <>
      <button onClick={() => selectTab('posts')}>
        {isPending ? 'Loading...' : 'Posts'}
      </button>
      {tab === 'posts' && <SlowPostsTab />}
    </>
  );
}

結果:

  • ボタンクリック後も他の操作が可能
  • isPending でローディング状態を表示
  • 途中で別のタブをクリックすれば、前の遷移を中断

1.3 useTransition の API

const [isPending, startTransition] = useTransition()
返り値 説明
isPending トランジションが保留中(進行中)かどうかを示すブール値
startTransition 更新をトランジションとしてマークする関数

startTransition の引数:

引数 説明
action 1つ以上の setState を呼び出す関数(「アクション」と呼ばれる)
startTransition(() => {
  // この中のすべての setState がトランジションとしてマークされる
  setTab(nextTab);
  setData(newData);
});

💡 アクション (Action) とは
startTransition に渡される関数は「アクション」と呼ばれます。慣習として、コールバック props は action という名前にするか、末尾に Action サフィックスをつけるとよいでしょう。

1.4 似ているフックとの比較

useTransition vs useDeferredValue

項目 useTransition useDeferredValue
制御対象 state 更新全体 特定の値のみ
使い方 関数をラップ 値をラップ
保留状態 isPending で取得可 なし
使用場面 更新を自分で制御できる場合 props で受け取った値を遅延させたい場合
// useTransition: state 更新を制御
const [isPending, startTransition] = useTransition();
startTransition(() => {
  setQuery(input);
});

// useDeferredValue: 値を遅延
const deferredQuery = useDeferredValue(query);

useTransition vs 通常の setState

項目 useTransition 通常の setState
優先度 低優先度(中断可能) 高優先度
ブロッキング なし あり
中断 可能 不可
Suspense 連携 フォールバック抑制 フォールバック表示

1.5 重要な注意点

ルール 説明
ノンブロッキング ユーザー操作を妨げない
⚠️ 同期関数のみ startTransition に渡す関数は同期的であるべき
⚠️ 制御可能な更新のみ setState にアクセスできる場合のみ使用可能
入力フィールドには使用不可 テキスト入力の制御には使えない
// ❌ 入力フィールドには使用できない
function SearchBox() {
  const [text, setText] = useState('');
  
  function handleChange(e) {
    // トランジションで入力値を更新すると、タイピングが遅延する
    startTransition(() => {
      setText(e.target.value);  // NG!
    });
  }
  
  return <input value={text} onChange={handleChange} />;
}

// ✅ 入力と検索結果を分離する
function SearchBox() {
  const [text, setText] = useState('');
  const [results, setResults] = useState([]);
  
  function handleChange(e) {
    setText(e.target.value);  // 入力は即座に更新
    startTransition(() => {
      setResults(search(e.target.value));  // 検索結果はトランジション
    });
  }
  
  return <input value={text} onChange={handleChange} />;
}

2. useTransition の内部構造を徹底解剖

2.0 全体像: useTransition の処理フロー

🎣 useTransition(フック呼び出し)
   ↓
📝 初回: mountTransition / 更新: updateTransition
   ↓
📋 state フック(isPending用)を作成
   ↓
🔗 startTransition 関数を Fiber & Queue にバインド
   ↓
🏃 startTransition 実行時:
   ├── 1. 現在の優先度を保存
   ├── 2. ContinuousEventPriority に設定(低優先度化)
   ├── 3. ReactSharedInternals.T にトランジション情報をセット
   ├── 4. isPending を true に更新(optimistic update)
   ├── 5. アクション(コールバック)を実行
   └── 6. 完了後に isPending を false に更新

2.1 エントリポイント: packages/react/src/ReactHooks.js

// packages/react/src/ReactHooks.js

export function useTransition(): [
  boolean,
  (callback: () => void, options?: StartTransitionOptions) => void,
] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useTransition();
}

引用元: packages/react/src/ReactHooks.js#L170-L175

useTransition が呼ばれると、現在のディスパッチャの useTransition メソッドが呼び出されます。

2.2 コア実装: mountTransitionupdateTransition

初回レンダー時の処理 (mountTransition)

// packages/react-reconciler/src/ReactFiberHooks.js

function mountTransition(): [
  boolean,
  (callback: () => void, options?: StartTransitionOptions) => void,
] {
  const stateHook = mountStateImpl((false: Thenable<boolean> | boolean));
  // The `start` method never changes.
  const start = startTransition.bind(
    null,
    currentlyRenderingFiber,
    stateHook.queue,
    true,   // pendingState
    false,  // finishedState
  );
  const hook = mountWorkInProgressHook();
  hook.memoizedState = start;
  return [false, start];
}

引用元: packages/react-reconciler/src/ReactFiberHooks.js#L3398-L3415

ポイント:

  1. mountStateImpl(false)isPending 用の state フックを作成(初期値 false
  2. startTransition 関数を現在の Fiber と Queue にバインド
  3. start 関数は変わらない(安定した参照)
  4. [false, start] を返す

更新時の処理 (updateTransition)

// packages/react-reconciler/src/ReactFiberHooks.js

function updateTransition(): [
  boolean,
  (callback: () => void, options?: StartTransitionOptions) => void,
] {
  const [booleanOrThenable] = updateState(false);
  const hook = updateWorkInProgressHook();
  const start = hook.memoizedState;
  const isPending =
    typeof booleanOrThenable === 'boolean'
      ? booleanOrThenable
      : // This will suspend until the async action scope has finished.
        useThenable(booleanOrThenable);
  return [isPending, start];
}

引用元: packages/react-reconciler/src/ReactFiberHooks.js#L3417-L3430

ポイント:

  1. updateState で現在の isPending 値を取得
  2. 値が boolean ならそのまま使用、Thenable なら useThenable でアンラップ
  3. start 関数は memoizedState から取得(常に同じ参照)

2.3 startTransition の実装

// packages/react-reconciler/src/ReactFiberHooks.js

function startTransition<S>(
  fiber: Fiber,
  queue: UpdateQueue<S | Thenable<S>, BasicStateAction<S | Thenable<S>>>,
  pendingState: S,
  finishedState: S,
  callback: () => mixed,
  options?: StartTransitionOptions,
): void {
  const previousPriority = getCurrentUpdatePriority();
  // 低優先度(ContinuousEventPriority)に設定
  setCurrentUpdatePriority(
    higherEventPriority(previousPriority, ContinuousEventPriority),
  );

  const prevTransition = ReactSharedInternals.T;
  const currentTransition: Transition = ({}: any);
  
  // トランジション情報を設定
  if (enableViewTransition) {
    currentTransition.types = prevTransition !== null 
      ? prevTransition.types 
      : null;
  }
  
  ReactSharedInternals.T = currentTransition;
  
  // isPending を true に設定(楽観的更新)
  dispatchOptimisticSetState(fiber, false, queue, pendingState);

  try {
    const returnValue = callback();
    
    // 非同期アクションの場合の処理
    if (
      returnValue !== null &&
      typeof returnValue === 'object' &&
      typeof returnValue.then === 'function'
    ) {
      const thenable = ((returnValue: any): Thenable<mixed>);
      const thenableForFinishedState = chainThenableValue(
        thenable,
        finishedState,
      );
      dispatchSetStateInternal(
        fiber,
        queue,
        (thenableForFinishedState: any),
        requestUpdateLane(fiber),
      );
    } else {
      // 同期アクションの場合
      dispatchSetStateInternal(
        fiber,
        queue,
        finishedState,
        requestUpdateLane(fiber),
      );
    }
  } catch (error) {
    // エラーをラップして再スロー
    const rejectedThenable: RejectedThenable<S> = {
      then() {},
      status: 'rejected',
      reason: error,
    };
    dispatchSetStateInternal(
      fiber,
      queue,
      rejectedThenable,
      requestUpdateLane(fiber),
    );
  } finally {
    setCurrentUpdatePriority(previousPriority);
    ReactSharedInternals.T = prevTransition;
  }
}

引用元: packages/react-reconciler/src/ReactFiberHooks.js#L3090-L3233

ポイント:

  1. 優先度の切り替え: ContinuousEventPriority(低優先度)に設定することで、他の高優先度更新に割り込まれる
  2. 楽観的更新: dispatchOptimisticSetState で即座に isPending = true を設定
  3. 非同期サポート: コールバックが Promise を返す場合、完了まで isPending を維持
  4. エラーハンドリング: エラーは rejectedThenable としてラップされ、useTransition から再スローされる

2.4 レーンシステム: トランジションの優先度管理

React のレーン(Lane)システムは、更新の優先度を管理します。トランジションには専用のレーンが割り当てられています。

// packages/react-reconciler/src/ReactFiberLane.js

const TransitionLanes: Lanes = /*                       */ 0b0000000001111111111111100000000;
const TransitionLane1: Lane = /*                        */ 0b0000000000000000000000100000000;
const TransitionLane2: Lane = /*                        */ 0b0000000000000000000001000000000;
const TransitionLane3: Lane = /*                        */ 0b0000000000000000000010000000000;
// ... TransitionLane14 まで存在

export const SomeTransitionLane: Lane = TransitionLane1;

引用元: packages/react-reconciler/src/ReactFiberLane.js#L61-L79

レーンの優先度順序:

高優先度 ←────────────────────────────→ 低優先度

SyncLane → InputContinuousLane → DefaultLane → TransitionLanes → RetryLanes → IdleLane
(同期)      (連続入力)        (デフォルト)   (トランジション)    (リトライ)    (アイドル)
レーン 用途
SyncLane 同期的な更新 ReactDOM.flushSync
InputContinuousLane 連続入力 テキスト入力、スクロール
DefaultLane 通常の更新 一般的な setState
TransitionLanes トランジション startTransition
IdleLane アイドル時 低優先度のバックグラウンド処理

2.5 トランジションオブジェクトの構造

// packages/react/src/ReactStartTransition.js

export type Transition = {
  types: null | TransitionTypes,    // enableViewTransition
  gesture: null | GestureProvider,  // enableGestureTransition
  name: null | string,              // enableTransitionTracing only
  startTime: number,                // enableTransitionTracing only
  _updatedFibers: Set<Fiber>,       // DEV-only
  ...
};

引用元: packages/react/src/ReactStartTransition.js#L30-L37

ReactSharedInternals.T にトランジションオブジェクトが格納されます。これにより、React 内部で現在トランジション中かどうかを判定できます。

2.6 Suspense との連携

トランジションは Suspense と密接に連携し、ローディングフォールバックの表示を制御します。

// 通常の更新: フォールバックが表示される
setTab('posts');  // → <Suspense> のフォールバックが表示

// トランジション: フォールバックが抑制される
startTransition(() => {
  setTab('posts');  // → 前のタブが表示されたまま(isPending = true)
});

内部的には、トランジションレーンでの更新は Suspense バウンダリの「表示抑制」フラグをセットします。

3. ユースケース

3.1 タブ切り替えの最適化

重いコンテンツを持つタブ間の切り替えを滑らかにする最も一般的なパターン:

function TabContainer() {
  const [tab, setTab] = useState('about');
  const [isPending, startTransition] = useTransition();

  function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <>
      <TabButtons 
        activeTab={tab} 
        isPending={isPending} 
        onSelect={selectTab} 
      />
      <TabContent tab={tab} />
    </>
  );
}

3.2 フィルタリング・検索結果の遅延表示

検索入力で即時レスポンスを維持しつつ、重い結果表示を遅延:

function SearchableList({ items }) {
  const [query, setQuery] = useState('');
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();

  function handleSearch(e) {
    const value = e.target.value;
    setQuery(value);  // 入力は即座に反映
    
    startTransition(() => {
      // 重いフィルタリングはトランジション
      setFilteredItems(items.filter(item => 
        item.name.toLowerCase().includes(value.toLowerCase())
      ));
    });
  }

  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending && <LoadingIndicator />}
      <List items={filteredItems} />
    </div>
  );
}

3.3 ルーターナビゲーション

ページ遷移をトランジションでラップして、滑らかなナビゲーションを実現:

function Router() {
  const [page, setPage] = useState('/');
  const [isPending, startTransition] = useTransition();

  function navigate(url) {
    startTransition(() => {
      setPage(url);
    });
  }

  return (
    <Suspense fallback={<Spinner />}>
      <NavBar navigate={navigate} isPending={isPending} />
      <PageContent page={page} />
    </Suspense>
  );
}

3.4 非同期アクションの実行

startTransition は非同期関数もサポート(React 19+):

function CheckoutForm() {
  const [quantity, setQuantity] = useState(1);
  const [isPending, startTransition] = useTransition();

  async function updateQuantityAction(newQuantity) {
    startTransition(async () => {
      const savedQuantity = await updateQuantity(newQuantity);
      // ⚠️ await 後の setState は別の startTransition が必要
      startTransition(() => {
        setQuantity(savedQuantity);
      });
    });
  }

  return (
    <div>
      <QuantitySelector 
        value={quantity} 
        onChange={updateQuantityAction}
        disabled={isPending}
      />
      {isPending && <span>Updating...</span>}
    </div>
  );
}

3.5 親コンポーネントへのアクション公開

コンポーネントが props として action を公開するパターン:

function TabButton({ action, children, isActive }) {
  const [isPending, startTransition] = useTransition();
  
  if (isActive) return <b>{children}</b>;
  if (isPending) return <b className="pending">{children}</b>;
  
  return (
    <button onClick={async () => {
      startTransition(async () => {
        // action は同期でも非同期でも OK
        await action();
      });
    }}>
      {children}
    </button>
  );
}

// 使用側
<TabButton action={() => setTab('posts')}>Posts</TabButton>

4. トラブルシューティング

4.1 トランジション中に入力フィールドを更新できない

// ❌ これは動作しない
function handleChange(e) {
  startTransition(() => {
    setText(e.target.value);
  });
}

解決策: 入力用の state とトランジション用の state を分離する

function handleChange(e) {
  const value = e.target.value;
  setText(value);  // 即座に更新
  startTransition(() => {
    setSearchResults(search(value));  // これはトランジション
  });
}

4.2 await 後の setState がトランジションにならない

// ❌ await 後の setState はトランジション外
startTransition(async () => {
  await fetchData();
  setData(newData);  // トランジションとしてマークされない
});

// ✅ await 後も startTransition でラップ
startTransition(async () => {
  await fetchData();
  startTransition(() => {
    setData(newData);
  });
});

4.3 setTimeout 内の setState がトランジションにならない

// ❌ setTimeout 内はトランジション外
startTransition(() => {
  setTimeout(() => {
    setPage('/about');  // トランジションにならない
  }, 1000);
});

// ✅ setTimeout 内で startTransition を呼ぶ
setTimeout(() => {
  startTransition(() => {
    setPage('/about');
  });
}, 1000);

5. まとめ

useTransition は React の Concurrent Features の中核をなすフックです。

特徴 説明
ノンブロッキング更新 ユーザー操作を妨げない
中断可能なレンダリング 新しい更新が来たら前の処理を中断
Suspense 連携 フォールバック表示を抑制
保留状態の追跡 isPending でローディング UI を表示
優先度制御 低優先度レーンで処理

使用すべき場面:

  • タブ切り替え、ページナビゲーション
  • 検索・フィルタリング結果の表示
  • 非同期データの取得と表示
  • 重い計算を伴う UI 更新

避けるべき場面:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?