memo.nnenn0.com

ReactのstartTransitionで包んだ更新はどこで後回しにされるのか

startTransitionは、stateの更新をTransitionとして扱うためのAPIだ。公式ドキュメントでは次のように説明されている。

The startTransition function lets you mark a state update as a Transition.

React公式: startTransition

例えば検索欄で、入力欄の表示はすぐ更新し、重い一覧の更新はTransitionにする。

tsx
// 重い一覧。項目ごとのコンポーネントをたくさん描き、項目ごとに時間がかかる。
// memoで包み、queryが前回と同じなら再レンダーを飛ばす
const SlowList = memo(SlowListImpl);

function App() {
  const [text, setText] = useState("");
  const [query, setQuery] = useState("");

  return (
    <>
      <input
        value={text}
        onChange={(e) => {
          setText(e.target.value); // すぐ反映される
          startTransition(() => setQuery(e.target.value)); // 急ぎの更新の後で反映される
        }}
      />
      <SlowList query={query} />
    </>
  );
}

SlowListはmemoで包んでいるので、setTextだけでqueryが変わらない急ぎのレンダーでは、重い一覧の再レンダーを飛ばせる。

Transitionのレンダーはコンポーネントの処理の合間でブラウザに処理を返せるので、重さが項目ごとのコンポーネントに分かれていれば、一覧を描いている途中でも入力を受け付けられる。入力で急ぎの更新が来ると、作りかけのTransitionのレンダーを捨てて急ぎのレンダーを先に済ませ、その後でTransitionのレンダーを最初からやり直す。そのためSlowListの描画が重くても、入力欄の文字は引っかからずに反映される。1つのコンポーネントの中で長く重い処理をすると、その処理の間は途中で止められない。

React公式: memo — Skipping re-rendering when props are unchanged、React公式: startTransition — Caveats、ReactFiberWorkLoop.js — workLoopConcurrent

よく聞く説明は「Transitionの更新は優先度が低いので後回しにされる」だ。使う側から見る挙動としてはこれで十分だけど、優先度という情報がどこに付いているのか、後回しにされた更新がその間どうなっているのかは、この説明からは分からない。

React 18でstartTransitionが出てきたとき、優先度が低い更新を後回しにできるもの、くらいの理解で使っていて、そういうものなんだなで止まっていることにずっとモヤっとしていたので、React v19.3.1の実装を追ってみた。

先に答えを書いておくと、冒頭のコードのようにイベントハンドラの中でuseStateの更新をstartTransitionで包んだ場合、急ぎのレンダーでは、Transitionのlaneが付いた更新を捨てずに飛ばしてHookのbaseQueueに残し、後のTransitionのレンダーで呼んだ順番どおりに適用し直している。

更新ごとに付くlane

setStateを呼んでも、stateはその場では書き換わらない。ReactはUpdateというオブジェクトを作ってHookのキューに積み、次のレンダーで順に適用する。UpdateはsetStateを1回呼んだことの記録で、型の定義から問いに関係するフィールドを抜き出すと次のようになっている。

js
export type Update<S, A> = {
  lane: Lane,
  action: A,
  next: Update<S, A>,
  // ...
};

ReactFiberHooks.js — Update

actionはsetStateに渡した値や関数で、laneがこの更新の優先度になる。laneはsetStateを呼んだ時点でrequestUpdateLaneが決めていて、startTransitionの中で呼ばれていればTransition用のlaneを返す。この分岐を抜き出すと次のようになっている。

js
const transition = requestCurrentTransition();
if (transition !== null) {
  // ...
  return requestTransitionLane(transition);
}

const priority = resolveUpdatePriority();
return eventPriorityToLane(priority);

ReactFiberWorkLoop.js — requestUpdateLane

startTransitionの外では、Reactのイベントハンドラの中なら、React DOMがイベントの種類ごとに設定した優先度を使う。入力欄のonChangeを呼ぶブラウザのinputやchangeイベントは、clickと同じくイベントの優先度の中で一番高いものに分類されていて、SyncLaneになる。setTimeoutのコールバックのように、クリックなどのイベントの処理が終わった後で呼んだ場合はDefaultLaneになる。

ReactDOMEventListener.js — getEventPriority、ReactDOMEventListener.js — dispatchDiscreteEvent、ReactDOMUpdatePriority.js — resolveUpdatePriority

イベントの優先度はEventPriorityという型になっているけど、中身はLaneそのもので、clickやinputの優先度はSyncLaneと同じ値になっている。

ReactEventPriorities.js

つまり冒頭のコードでは、1回のonChangeで次の2つのUpdateができる。図では、Transition用のlaneのどれかをTransitionLaneと書く。

text
setText(...)                         -> Update { lane: SyncLane }
startTransition(() => setQuery(...)) -> Update { lane: TransitionLane }

laneの値

laneの値はReactFiberLane.jsに31ビットの2進数で並んでいる。一部を抜き出すと次のようになっている。

js
export const NoLane: Lane = /*            */ 0b0000000000000000000000000000000;
export const SyncHydrationLane: Lane = /* */ 0b0000000000000000000000000000001;
export const SyncLane: Lane = /*          */ 0b0000000000000000000000000000010;
// ...
export const DefaultLane: Lane = /*       */ 0b0000000000000000000000000100000;
// ...
const TransitionLane1: Lane = /*          */ 0b0000000000000000000000100000000;
const TransitionLane2: Lane = /*          */ 0b0000000000000000000001000000000;
// ...
export const IdleLane: Lane = /*          */ 0b0010000000000000000000000000000;

ReactFiberLane.js

1つのlaneはどれか1ビットだけが立った数で、ビットが小さいほど優先度が高い。最下位の1は、サーバーで描画したHTMLにReactを結び付けるHydration用のSyncHydrationLaneが使っていて、SyncLaneはその次の2になる。3(0b11)のような値は1本のlaneには使わず、SyncHydrationLaneとSyncLaneの両方を表す。

Transition用のlaneは複数あり、startTransitionの更新にはそのどれかが付く。

ReactFiberLane.js — TransitionUpdateLanes

1ビットずつ別の位置を使っているので、複数のlaneを1つの数にまとめて持てる。ReactはこれをLanesという型で扱い、まとめる操作と、あるlaneが含まれるかの判定をビット演算で書いている。

js
export function isSubsetOfLanes(set: Lanes, subset: Lanes | Lane): boolean {
  return (set & subset) === subset;
}

export function mergeLanes(a: Lanes | Lane, b: Lanes | Lane): Lanes {
  return a | b;
}

ReactFiberLane.js — isSubsetOfLanes、mergeLanes

Hookの更新キュー

useStateを1回呼ぶごとにHookが1つでき、Hookはそれぞれ自分の更新キューを持っている。冒頭のAppならtextとqueryの2つのHookがあり、setTextのUpdateはtextのHookのキューに、setQueryのUpdateはqueryのHookのキューにつながる。Hookの型は次のようになっている。

js
export type Hook = {
  memoizedState: any,
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: any,
  next: Hook | null,
};

ReactFiberHooks.js — Hook

Hook自体はFiberのmemoizedStateからつながるlinked listになっていて、これはuseの記事のFiberのHook listで書いた。

FiberのmemoizedStateは最初のHookを指し、HookのmemoizedStateはそのHookの今のstateを持っていて、同じ名前でも中身が違う。

baseStateとbaseQueueは、後回しにした更新を後で適用し直すときに使う。useStateのHookでは、queueにマウント時に作ったUpdateQueueが入っていて、そのpendingがキューにつながったUpdateを指している。

js
export type UpdateQueue<S, A> = {
  pending: Update<S, A> | null,
  // ...
};

ReactFiberHooks.js — UpdateQueue、ReactFiberHooks.js — mountStateImpl

ここでnextが2種類出てくるので区別しておく。

キューはUpdate.nextでつながる循環リストで、queue.pendingは末尾のUpdateを指し、末尾のnextが先頭を指す。Updateをキューにつなぐ処理は次のようになっている。

js
const pending = queue.pending;
if (pending === null) {
  // This is the first update. Create a circular list.
  update.next = update;
} else {
  update.next = pending.next;
  pending.next = update;
}
queue.pending = update;

ReactFiberConcurrentUpdates.js — finishQueueingConcurrentUpdates

a、b、cの順につなぐと、次のようになる。

text
a をつなぐ(キューは空)
  pending -> a
  a.next  -> a              自分自身を指す

b をつなぐ
  pending -> b              末尾
  b.next  -> a              末尾の次は先頭
  a.next  -> b

c をつなぐ
  pending -> c              末尾
  c.next  -> a              末尾の次は先頭
  b.next  -> c

pendingは末尾のUpdateそのものを指しているので、pending.nextは末尾のnext、つまり先頭になる。cをつなぐときは、c.nextに先頭のa(pending.next)を入れ、末尾だったb(pending)のnextをcにして、pendingをcに替えるだけで済む。処理する側もpending.nextから先頭を取れる。先頭だけを指す普通のリストだと、末尾に足すたびに先頭からたどることになる。

ここでは、イベントハンドラから呼ぶ通常のsetStateを追う。この場合、Updateがこのキューにつながるのは、setStateを呼んだ瞬間ではない。setStateの時点では、UpdateはReact全体で共有する配列に置いておき、レンダーを始めるときにまとめてHookのキューにつなぐ。

ReactFiberConcurrentUpdates.js — enqueueUpdate、ReactFiberWorkLoop.js — prepareFreshStack

一方、更新元のFiberのlanesには、setStateを呼んだ時点ですぐに印が付く。

js
fiber.lanes = mergeLanes(fiber.lanes, lane);

ReactFiberConcurrentUpdates.js — enqueueUpdate

冒頭のコードなら、AppのFiberのlanesにはSyncLaneとTransitionのlaneの両方のビットが立ち、「このFiberには保留中の更新がある」ことを表す。

text
AppのFiber
  lanes: SyncLane | TransitionLane
  memoizedState
    -> Hook(text)
         queue.pending -> Update { lane: SyncLane }
    -> Hook(query)
         queue.pending -> Update { lane: TransitionLane }

急ぎのレンダーで飛ばされるUpdate

レンダーはsetStateを呼んだその場では始まらない。setStateはレンダーを予約するだけで、ボタンのonClickのような通常のイベントハンドラでは、イベントの処理が終わった後のマイクロタスクで、予約されたレンダーを処理する。処理するlaneにSyncLaneが含まれていればそのマイクロタスクの中でレンダーし、Transitionなどほかのlaneは、Schedulerのタスクとして後からレンダーする。

冒頭のコードのようにvalueを渡した入力欄(制御された入力欄)では、React DOMが入力欄の値を正しく戻すために、イベントの処理の最後でSyncLaneのレンダーを同期的に済ませる。そのため冒頭のコードのonChangeでは、急ぎのレンダーがマイクロタスクを待たずに走り、次の順番になる。

text
onChange: setText, setQuery
  |
  v
end of event: SyncLane render -> commit    入力欄が更新される
  |
  v
Scheduler task: TransitionLane render -> commit    SlowListが更新される

ChangeEventPlugin.js — createAndAccumulateChangeEvent、ReactDOMUpdateBatching.js — finishEventHandler、ReactFiberRootScheduler.js — processRootScheduleInMicrotask、ReactFiberRootScheduler.js — scheduleTaskForRootDuringMicrotask

今回のレンダーで処理するlane(renderLanes)は、ルートに集めた保留中のlaneから決める。setStateを呼ぶとそのlaneがルートのpendingLanesに足され、レンダーを始める前にgetNextLanesが優先度の高いものを選ぶ。冒頭のコードなら、1回目はSyncLane、それをコミットした後の2回目はTransitionのlaneが選ばれる。

ReactFiberLane.js — markRootUpdated、ReactFiberLane.js — getNextLanes

SyncLaneでレンダーするとき、queryのHookのキューにはTransitionのlaneが付いたUpdateが入っている。

useStateの更新はupdateReducerImplで処理される。キューを先頭から1つずつ見て、UpdateのlaneがrenderLanesに含まれるかをisSubsetOfLanesで判定する。ここに関係しない分岐を省いて単純化すると、判定は次の1行になる。

js
const shouldSkipUpdate = !isSubsetOfLanes(renderLanes, updateLane);

ReactFiberHooks.js — updateReducerImpl

含まれないUpdateは適用せずに飛ばすが、捨てはしない。複製して新しいbaseQueueの末尾につなぎ、Fiberのlanesにもそのlaneの印をもう一度付ける。Fiberのlanesはコンポーネントをレンダーする直前にいったん空にされているので、レンダーが終わったときには、飛ばしたUpdateのlaneだけが印として残る。

ReactFiberBeginWork.js — beginWork

この部分を抜き出すと次のようになっている。newBaseStateの行は、最初に飛ばしたときに、それまでに適用したstateを取っておく処理になっている。

js
if (shouldSkipUpdate) {
  const clone: Update<S, A> = {
    lane: updateLane,
    action: update.action,
    // ...
  };
  if (newBaseQueueLast === null) {
    newBaseQueueFirst = newBaseQueueLast = clone;
    newBaseState = newState;
  } else {
    newBaseQueueLast = newBaseQueueLast.next = clone;
  }
  currentlyRenderingFiber.lanes = mergeLanes(
    currentlyRenderingFiber.lanes,
    updateLane,
  );
  // ...
}

ReactFiberHooks.js — updateReducerImpl

ループが終わると、結果をHookに書き込む。

js
hook.memoizedState = newState;
hook.baseState = newBaseState;
hook.baseQueue = newBaseQueueLast;

ReactFiberHooks.js — updateReducerImpl

memoizedStateはこのレンダーで使う値で、baseStateとbaseQueueは後でもう一度適用するときの出発点と、そこから適用するUpdateになる。冒頭のコードで、abと入力してある欄にcを足すと、急ぎのレンダーの後の2つのHookは次のようになる。

text
Hook(text)
  memoizedState: "abc"    適用済み
  baseQueue: null

Hook(query)
  memoizedState: "ab"     まだ古い値
  baseState: "ab"
  baseQueue: Update { lane: TransitionLane, action: "abc" }

AppのFiber
  lanes: TransitionLane

textだけが新しい値になり、queryは古い値のままコミットされる。Fiberのlanesに残したTransitionのlaneは、レンダーの終わりに、親のFiberが持つchildLanes(子孫に保留中の更新があることを示す印)を通ってルートまで集められ、コミットの後もまだ処理していないlaneとして残る。そのためReactは後でTransitionのlaneでもう一度レンダーし、そのときにbaseStateの"ab"からbaseQueueのUpdateを適用してqueryが"abc"になる。

ReactFiberCompleteWork.js — bubbleProperties、ReactFiberWorkLoop.js — commitRoot

baseStateと適用済みのUpdate

前の節では、飛ばしたUpdateをbaseQueueに残した。では、飛ばしたUpdateより後ろにある、今回のレンダーで適用するUpdateはどうなるのか。関数で更新する例で見てみる。1回のクリックで、急ぎ、Transition、急ぎの順にsetCountを呼ぶ。

tsx
function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button
      onClick={() => {
        setCount((c) => c + 1); // A: SyncLane
        startTransition(() => setCount((c) => c * 10)); // B: TransitionLane
        setCount((c) => c + 100); // C: SyncLane
      }}
    >
      {count}
    </button>
  );
}

実装どおりなら、クリックの後の2回のレンダーは次のように進む。

text
1st render (renderLanes = SyncLane)
  baseState = 0
  A  newState = 1        適用
  B  newBaseState = 1    飛ばす
  C  newState = 101      適用して、NoLaneで複製
  -> memoizedState = 101
     baseState     = 1
     baseQueue     = [B, C(NoLane)]

2nd render (renderLanes = TransitionLane)
  baseState = 1
  B  newState = 10       適用
  C  newState = 110      適用

図の1回目では、Bを飛ばした時点で、それまでに適用したstateは1になっている。Reactはこの1をbaseStateとして取っておき、2回目のレンダーはここからやり直す。前の節のコードにあったnewBaseState = newStateがこの処理で、baseStateには最初に飛ばしたUpdateの直前のstateが入る。

ReactFiberHooks.js — updateReducerImpl

1回目にコミットした101から始めてBだけを適用すると、AとCの後にBを適用することになり、(0 + 1 + 100) * 10 = 1010になってしまう。setCountを呼んだ順番どおりに適用するには、Bの直前の1まで戻ってから、BとCを適用し直す必要がある。

baseQueueにBしか残っていなければ、2回目は1にBだけを適用して10になり、Cの+100が消えてしまう。そのため、最初に飛ばしたUpdateより後ろにあるUpdateは、今回適用するものでもbaseQueueに複製して残す。このときlaneはNoLaneにする。

js
if (newBaseQueueLast !== null) {
  const clone: Update<S, A> = {
    // This update is going to be committed so we never want uncommit
    // it. Using NoLane works because 0 is a subset of all bitmasks, so
    // this will never be skipped by the check above.
    lane: NoLane,
    action: update.action,
    // ...
  };
  newBaseQueueLast = newBaseQueueLast.next = clone;
}

ReactFiberHooks.js — updateReducerImpl

Cを元のSyncLaneのまま残すと、2回目はrenderLanesがTransitionのlaneなのでisSubsetOfLanesの判定に通らず、また飛ばされてしまう。NoLaneは0なので(set & 0) === 0はどのrenderLanesでも成り立ち、どのレンダーでも必ず適用される。

React v19.3.1をNode.jsのjsdomで動かし、actの中でボタンにclickイベントを送って、レンダーごとのcountとコミット時のボタンの表示を記録した。結果は次のとおりで、どちらも0 -> 101 -> 110だった。先頭の0はクリックより前の最初の表示で、クリックで起きたレンダーは101と110の2回になる。

text
render: 0 -> 101 -> 110
commit: 0 -> 101 -> 110

actの中では、ReactはマイクロタスクやSchedulerの代わりにactのキューでレンダーを進める。そのため、この結果で確かめられるのは値と順番までで、前の節で書いたタイミングや、ブラウザが実際に画面を描いたかどうかまでは確かめていない。

1回目のコミットでは、Bを抜いてAとCだけを適用した101がDOMに反映されている。setCountを呼んだ順に1つずつ適用していけば1、10、110になるので、101はその途中には出てこない値になる。最終的な110は、A、B、Cを呼んだ順に0から適用した結果と同じになる。

まとめ

startTransitionで包んだsetStateは、UpdateにTransitionのlaneが付く。急ぎのSyncLaneはイベントの処理が終わるとすぐにレンダーし、Transitionのlaneは後からSchedulerのタスクでレンダーする。急ぎのレンダーではlaneが合わないUpdateを飛ばし、最初に飛ばした位置のstateをbaseState、それ以降のUpdateをbaseQueueとしてHookに残す。後でTransitionのlaneでレンダーするときに、baseStateから元の順番どおりに適用し直す。

「優先度が低いので後回し」は、1つのキューの中でUpdateごとにlaneを見て、今回のレンダーで適用するものと残すものを分けていると言い直せる。その間に、setStateを呼んだ順番どおりに適用していけば出てこない値が一度コミットされることもあるが、最後は呼んだ順番どおりに適用した結果になる。

startTransitionをそういうものとして使っていたけど、中を見てみるといろいろな工夫があった。複雑だなとも思ったけど、laneをビットにして、まとめたり含まれるかを確かめたりをビット演算で済ませているところは面白かった。

参考