Edited at

🎊Reactの2種類の新フック「useTransition」と「useDeferredValue」を最速で理解する(プレビュー版)

10月25日、Reactの新機能であるConcurrent Modeのプレビュー版が発表されました。この中には、Concurrent Modeの恩恵を得るために必要となる新しいAPIが含まれています。

これらのAPIの中心となるのが、Suspenseと2種類の新しいフックuseTransitionとuseDeferredValueです。この記事ではこの2種類のフックに焦点を当てて、これらが何をしてくれるのか、どのようにこれらが新しいのかを解説します。

要するに、Reactの公式ドキュメントを読んでまとめましたということです。特に、ガイドを一通り読んで理解しないとこれらのフックが何をしているのか理解しにくいため、最速で理解できるように要点をまとめ直しました。

なお、当該ドキュメントの最初にでかでかと書いてあるように、これらのAPIはまだプレビュー版であり、この記事に書いてあることからまったく別物に変化する可能性すらあります。ただ、少なくともこれらのAPIの基となる思想については今のうちに理解しておいて損はないでしょう。

以下ではいくつか例が出てきますが、これらの例は以下のリポジトリで公開しています。npm startでpercelのサーバーが立ち上がるので適当に試してみてください。

※ 追記:当初「React 16.10」と記事タイトルに入っていましたが間違いだったので消しました。concurremt modeがReactのどのバージョンで正式リリースされるかは未定です。


3行でまとめると

最速といってもそこそこ長くなってしまったので3行まとめを用意しました。


  • Reactがレンダリングを中断したり並行実行できるようになるよ


  • useTransitionは今の画面を残しつつ次の画面を裏でレンダリングしたいときに使えるよ


  • useDeferredValueは値の遷移をいい感じに遅延してほしいときに使えるよ


Suspenseの基礎

さて、では解説に入っていきます。

ただ、本題に入る前にSuspenseの基礎について理解していただく必要があります。今回解説するフック(特にuseTransition)がSuspenseに深く関連するものである以上これは仕方がありません。もう知ってるよという方は本題のフックを最速で理解するために次の節まで飛ばしましょう。

以下で解説する内容も今回のプレビュー版ではじめて利用可能になったものですが、コンセプト自体は前々から公開されていましたので知っている方もいるかと思います。

Suspenseの最大の特徴はコンポーネントがPromiseを投げるということです。


Promiseを投げるコンポーネント

では、最初の例を見ましょう。上記のリポジトリを動かしている方は、「01 Example of Suspense」という部分です。

この例は読み込みに時間がかかるコンテンツを表示するという状況を想定した単純な例です。「追加コンテンツを表示」と書かれたボタンが表示されており、押すと「Loading...」という表示に切り替わります。さらに2秒後、「外部から読み込んだ何らかのデータ」という文字列が表示されます。

コードを前半と後半に分けて示します。


01-Suspense-example.tsx(前半)

export const Example01 = () => {

const [showChild, setShowChild] = useState(false);

return (
<>
<h1>01 Example of Suspense</h1>
{showChild ? (
<Suspense fallback={<p>loading...</p>}>
<AdditionalContents />
</Suspense>
) : (
<button onClick={() => setShowChild(true)}>追加コンテンツを表示</button>
)}
</>
);
};


これは何の変哲もないコンポーネントで、「追加コンテンツを表示」ボタンを押すとshowChildというステートがtrueに切り替わって<AdditionalContents />が表示されます。今回その周りをSuspenseで囲んでいます。

このAdditionalContentsは追加コンテンツを表示するコンポーネントなのですが、追加コンテンツはボタンを押してからダウンロードする必要があるということにしましょう。もちろんダウンロードには時間がかかります。このコンポーネントは以下のように作りました。


01-Suspense-example.tsx(後半)

const AdditionalContents = () => {

// データをロードしてそのデータを表示
const data = getData();
return <p>{data}</p>;
};

let loadedData: string | null = null;
// 取得したデータを返す関数
// (まだ取得していないときは取得しつつPromiseを投げる
const getData = () => {
if (loadedData) {
// 取得済みなので返す
return loadedData;
} else {
throw loadData(0).then(data => {
loadedData = data;
});
}
};


AdditionalContentsコンポーネントはgetData関数を呼び出してデータを取得します。返り値は文字列です。getData関数はすぐ下で定義されています。

今回は手抜きの実装なのでloadedDataというグローバル変数があり、データのロードが完了した場合はここにデータが入っています。getDataはこのloadedDataが存在していればそれを直接返します。

データがまだロードされていない場合が問題です。この場合はloadData関数を呼び出してデータを読み込みます。loadDataはPromise<string>を返すので、データが来たらそれをloadedDataに代入します。

ここまでは良いのですが、何かthrowとかいう構文が見えますね。実はこれはPromiseをthrowしています。というのも、loadedData(0).then(...)というのはPromiseを返すからです。

このPromiseをthrowするというのがSuspense最大の特徴です。コンポーネントのレンダリング中にPromiseがthrowされた場合は、レンダリングがサスペンドされた(今はまだレンダリングできない)という扱いになります。なぜPromiseをthrowするのかといえば、「このPromiseが解決されたらもう一回試してくれ〜:pray:」という意思表示のためです。

今回の場合、getDataはまだデータが読みこまれていない場合、「データの読み込みが完了したら解決されるPromise」を投げます。これにより、データが読みこまれたらもう一度コンポーネントがレンダリングされることが期待されます。そのとき再度getDataが呼ばれますが、その場合はすでにデータがあるため再度Promiseをthrowする必要はありません。


Suspenseによるフォールバック

さて、コンポーネントをレンダリングしようとしたらPromiseを投げられてしまいました。これではレンダリング結果が存在しないのでコンポーネントをレンダリングできません。

これに対応するのがまさにSuspenseコンポーネントの役割です。コンポーネントのレンダリングがサスペンドした場合、これを囲むSuspenseの中身全体がフォールバックUI(Suspenseにfallback={...}で渡したもの)に切り替わります。

ボタンを押した瞬間に「Loading...」という表示が出たのはこのためです。ここでは、以下のことが起こっています。


  1. ボタンを押したことでsetShowChild(true)が実行され、showChildがtrueになる。


  2. Example01が再レンダリングされ、それに伴って<AdditionalContents />がレンダリングされる。


  3. AdditionalContentsのレンダリングがサスペンドする(Promiseが投げられる)。


  4. Suspenseがこれに反応してfallbackの内容がレンダリングされる。

  5. 「Loading...」と表示される。

その後は、loadData(0)が2秒で解決されるようになっているので(具体的な実装はサンプルのリポジトリをご覧ください)2秒後に<AdditionalContents />が再びレンダリングされます。今回はサスペンドしないため、Suspenseのフォールバックは消えて中身がちゃんとレンダリングされます。

以上がSuspenseの基本機能ですが、読者の中には「こんなの【ここに好きな状態管理ライブラリを入れよう】があればできるでしょ😆Promiseをthrowするなんて意味不明なことする意味なくない?😅」と思った方もいるでしょう。何かisLoadingみたいなステートを用意すれば同じことができますよね。

それに対する答えは、これから説明するフックを通じて明らかになります。

では、いよいよ本題に入りましょう。


useTransition

useTransitionフックは、冒頭で述べたように今の画面を残しつつ次の画面を裏でレンダリングすることを可能にするフックです。今の画面を残すというのは、DOMが今の状態を維持するということです。今回用意した例はこちらです。


02-useTransition-example.tsx(抜粋)

export const Example02 = () => {

const [showChild, setShowChild] = useState(false);
// ここでuseTransitionを使用
const [startTransition, isPending] = useTransition({
timeoutMs: 10000 // タイムアウトを10秒に設定
});

return (
<>
<h1>02 Example of useTransition</h1>
<Suspense fallback={<p>loading...</p>}>
{showChild ? (
<AdditionalContents />
) : (
<button
onClick={() => {
// setShowChildをstartTransitionで囲んだ
startTransition(() => {
setShowChild(true);
});
}}
>
追加コンテンツを表示
</button>
)}
</Suspense>
</>
);
};


AdditionalContentsの中身はさっきと同じなので省略しました。また、Suspenseの位置を都合により変更しています(AdditionalContentsの周りではなく条件分岐全体を囲むように。理由は後述)。

このサンプルは、先ほどとは少し異なる挙動をします。というのも、「追加コンテンツを表示」ボタンを押しても「Loading...」が表示されません。代わりに、2秒後まで「追加コンテンツを表示」ボタンが表示され続け、その後直接データが表示されます。

先ほどと同じような構成なのにこのような挙動の違いが生じるのは、もちろんuseTransitionのおかげです。


useTransitionの機能

useTransitionは、startTransition関数を得るのが主要な機能です。上のコードを見るともうひとつisPendingというものも取得していますが、これはあとで解説します。

startTransition関数は、コールバック関数でステートの更新をラップすることで効果を発揮します。具体例はボタンのonClickハンドラの内部で使用されています。

startTransition内部でステートが更新された場合、そのステート更新によって引き起こされた再レンダリングがサスペンドした場合はフォールバックを表示する代わりに今の状態を表示し続けます。

つまり、今回のサンプルの動作は以下のように説明できます。


  1. ボタンを押したことでsetShowChild(true)が実行され、showChildがtrueになる。


  2. Example02が再レンダリングされ、それに伴って<AdditionalContents />がレンダリングされる。


  3. AdditionalContentsのレンダリングがサスペンドする(Promiseが投げられる)。

  4. サスペンドが発生したため、useTransitionの効果により今の状態(setShowChild(true);される前の状態)がDOMに維持される。

3までは前と同じですが、useTransitionの効果により4で違うことが起こります。setShowChild(true);を実行したにも関わらず、その前の状態が表示され続けるのです。

そして、サスペンドしていたAdditionalContentsがレンダリング完了した時点で新しい状態(setShowChild(true);後の状態)が表示されます。

ちなみに、useTransitionにはtimeoutMsというオプションが渡されています(デフォルトは多分500ミリ秒くらいです)。これは、前の画面を表示し続ける限度時間です。この時間を超えてもコンポーネントがサスペンドされていた場合は、諦めてステート更新後の状態が表示されます(ただし、依然としてサスペンドしているコンポーネントがあるため従来通りSuspenseによるフォールバックが表示されます)。

また、Suspenseの位置を外側に移動した理由ですが、showChildの値に関わらずSuspenseが存在するようにするためです。どうやらここまで説明したuseTransitionの機能はSuspenseのマウント時には働かないようです。つまり、useTransition内でのステート更新によってSuspenseが新規に設置された場合は従来通りフォールバックが表示される挙動となります。

恐らく、Suspenseが存在しない場合の挙動を最適化するためにそういう仕様になっているのではないかと思います。いずれにせよ、Suspenseをどこに置くのかというのはどのコンポーネントのサスペンドをどこで対応するのかということにも関わるため、これからのReactを使いこなすにあたって重要な視点となります。

以上がstartTransitionの挙動でした。


isPendingの使用

さて、useTransitionフックはもうひとつisPendingという値を返しています。次はこれを解説します。

isPendingは真偽値で、「次の画面のレンダリングがサスペンドしたのでそれを待機している」という状況においてtrueとなります。

いまいち何を言っているのか分からないと思うので例を用意しました。先ほどとの違いは一箇所だけで、ボタンにdisabled={isPending}という属性を追加しました。


03-useTransition-Example2.tsx(抜粋)

export const Example03 = () => {

const [showChild, setShowChild] = useState(false);
// ここでuseTransitionを使用
const [startTransition, isPending] = useTransition({
timeoutMs: 10000 // タイムアウトを10秒に設定
});

return (
<>
<h1>03 Example of useTransition 2</h1>
<Suspense fallback={<p>loading...</p>}>
{showChild ? (
<AdditionalContents />
) : (
<button
// ↓ この行を追加した
disabled={isPending}
onClick={() => {
startTransition(() => {
setShowChild(true);
});
}}
>
追加コンテンツを表示
</button>
)}
</Suspense>
</>
);
};


この例は、「追加コンテンツを表示」ボタンを押すと即座にそのボタンが無効化(disabled)されてもう一度押せなくなります。他の点は先ほどと同じで、2秒後にボタンが消えてAdditionalContentsの中身が表示されます。

今回はdisabledだけですが、isPendingは結構便利です。例えば、次の画面のレンダリングがサスペンドしている間、前の画面にいい感じのローディング表示を出すこともできるでしょう。


useTransitionによる同時レンダリング

ここで何が起こっているのかを図で表してみました。上の例には、下の図でいうA, B, Cという3つの状態が存在します。AはshowChildとisPendingが両方ともfalseの状態で、BはisPendingがtrueになった状態、CはshowChildがtrueになった状態です。

useTransitionの動作の図解.png

初期状態はAであり、このコンポーネントをレンダリングするとDOMにはAの状態が表示されます。

ユーザーがボタンを押すとstartTransition内でステートの更新が行われます。つまり、プログラムによってCへの状態遷移が指示されたことになります。

ところが、useTransitionの働きにより、まずBがレンダリングされます。ポイントは、Cよりも先にBがレンダリングされるという点です。これは、なるべく早くユーザーにフィードバックを返すためという理由があります。

つまり、一般にCというのは全く新しい状態であり読み込み完了まで時間がかかるかもしれない一方で、Bというのは普通はAに対してユーザーへのフィードバックを加えた状態です(上の例だとボタンがdisabledになるというフィードバックがありました)。UX的な観点からフィードバックは速いほうが望ましいため、Bが先にレンダリングされることになります。

よって、ボタンを押すとまずBがレンダリングされてDOMに反映されます。その後でCのレンダリングが行われます。Cのレンダリングがサスペンドしなければ、Cのレンダリング結果がDOMに反映されます。Cがサスペンドした場合、DOMはBの状態を維持します。

Cがサスペンドしなかった場合はBをDOMに反映→CをDOMに反映という操作が連続して起きるのでBのレンダリングが無駄になってしまいますが、サスペンドした場合のUXを優先してこの挙動になっていると思われます。

同時レンダリングという割にはB→Cと直列にレンダリングが行われているように見えますが、Reactは内部的にBとCという2つの状態を同時に管理しているのであり、その意味では同時レンダリングと言えなくもありません。

また、Bの状態からさらにステートの更新(更新後をB'としましょう)を引き起こすという意地悪も可能です。この場合、Cのレンダリングがサスペンドしている状態でB'のレンダリングが行われることになり、同時レンダリング感が増します。さらに、この更新はC側にも反映されてC'が作られます。このときの挙動はcherry-pickのような感じです。


useTransitionの意義

ここまで説明したように、useTransitionはSuspenseと組み合わせることにより、画面の更新時にレンダリングがサスペンドする場合でのユーザーエクスペリエンスを向上させることができます。

ただ、似たような挙動は従来からあるステート管理の手法でも再現できるでしょう。ボタンを押した段階でisPendingというステートをtrueにして、ロードが完了した段階でshowChildをtrueにするようなロジックを書けばいいのです。

従来の手法の問題は、このようなロジックは中央集権的になりがちという点です。複数の画面にまたがるロジックである以上、ロジックを個々のコンポーネント内にしまい込んでしまうことは難しく、これは仕方がありません。逆に言えば、だからこそ、この問題をReact本体の追加機能として提供する価値があるとも言えます。

実際、useTransition(そしてSuspense)の意義はコンポーネント間のロジックの分離にあるように思えます。Suspenseを使う場合、「どんなデータをロードするか」とか「データの読み込み完了がいつか」といったロジックはそのデータを使うコンポーネント(上の例ではAdditionalContents)に内包されます。また、フィードバックを表示する側(isPendingを使う側)も、何がどうサスペンドしているのかは気にする必要がありません。

ここでのポイントは、ローディングのロジック(サスペンドするかどうかも含む)が「次の画面」に内包されていることです。こうなると、ローディングの画面を表示すべきかどうかも実際に次の画面をレンダリングしてみないと分からないのです。しかし、従来のReactでは次の画面をレンダリングしたら元の画面が消えてしまうという問題がありました。これを解決するために前の画面を残しつつ次の画面をレンダリングするという機構が必要になっているのです。

Reactはこのような方法で、中央集権的なロジックを脱してデータローディングをはじめとするロジックをコンポーネントの中に押し込めることに成功しました。

実際は“中央”がReactの内部に移動しただけかもしれませんが、その方がReactが“うまくやる“ことができると判断されたからこそこの機能があるのでしょう。将来的にはパフォーマンス上の恩恵などがあるかもしれません。今触ってみた限りでは、(UXではなく単純な処理速度の観点からは)直接パフォーマンスの向上に繋がっている感じはしませんでしたが。

また、そもそも中央集権的なステート管理が良いのか悪いのか、というのも答えのある問題ではありません。useTransitionはReactが示したひとつの答えであり、こういった領域に手を出してくるのは少しライブラリとしての厚みが増してきたなという感想を持たせられます。


useDeferredValue

もうひとつのフックはuseDeferredValueです。こちらは実はSuspenseと直接関係があるわけではないのですが、ReactがConcurrent Modeを実装したことにより可能になった機能です。


最初の例

一言で言うと、useDeferredValueは値の更新をいい感じに遅らせてくれるフックです。例を見ましょう。


04-useDeferredValue-example.tsx

export const Example04 = () => {

const [text, setText] = useState("");

const deferredText = useDeferredValue(text, { timeoutMs: 10000 });

console.log(text, deferredText);

return (
<>
<h1>04 Example of useDeferredValue</h1>
<p>
<input value={text} onChange={e => setText(e.currentTarget.value)} />
</p>
<p>{deferredText}</p>
</>
);
};


これは、useStateで宣言したtextというステートをinput要素を通じて変更できるというごく普通のUIです。

4行目でuseDeferredValueにtextを渡して、返り値としてdeferredTextを得ています。さらにこのdeferredTextをレンダリングしています。

実際にやってみると、inputに入力した内容が下に即座に反映されるように見えます。つまり、一見するとdeferredTextはtextと同じになっているように見えます。しかし、実際に起こっていることは少し異なります。それをこれから解説していきます。


useDeferredValueの機能

最初に述べたように、useDeferredValueは引数に渡した値をいい感じに遅延して返してくれるフックであると言えます。

この挙動はtextが変わった瞬間に効果を発揮します。textが変わると当然再レンダリングが発生しますが、deferredTextには変わる前のtextが入りますが。

その後いい感じのタイミングでもう一度再レンダリングが発生し、その際deferredTextに入るのは現在のtextとなります。

いい感じのタイミングというのは結局何かというと、Reactのスケジューリングに任せることになります。Reactが忙しくないときは一瞬で反映されますが、他の作業で忙しいときは結構反映が遅延されることになります。遅延の最大値を定めたい場合は

つまり、useDeferredValueの使い道は一部のコンポーネントのレンダリングをいい感じに遅延するという場合にあることになります。

実際、上の例ではconsole.logでtextとdeferredTextを並べて表示しているため、これを確かめてみましょう。入力欄に「abcde」と入力すると以下のようなログが並びます。左がtextで右がdeferredTextです。

a 

a
a a
a a
a a
a a
ab a
ab a
ab ab
ab ab
ab ab
ab ab
abc ab
abc ab
abc abc
abc abc
abc abc
abc abc
abcd abc
abcd abc
abcd abcd
abcd abcd
abcd abcd
abcd abcd
abcde abcd
abcde abcd
abcde abcde
abcde abcde
abcde abcde
abcde abcde

同じ状態が何回もレンダリングされているのは(多分strict modeみたいなものなので)気にしないことにして、よく見るとtextが更新されるタイミングとdeferredTextが更新されるタイミングが異なっています。例えばab aというログは、textが更新されて'ab'になったがdeferredTextはまだ前の状態である'a'である状態があったことを表しています。実際はその直後にdeferredTextも更新されて'ab'になるため、ab abというログが出ることになります。

このように、上の例でも実際にまずtextが更新されてからそれより遅れてdeferredTextが更新されていることが見て取れます。ただ、一瞬だったので目で見ても分かりませんでした。


useDeferredによる遅延を観察する

では、次の例では実際に遅延を観察してみましょう。そのためには、Reactに重い仕事をさせればいいことになります。とりあえずレンダリング量を増やしてみます。


05-useDeferredValue-example2.tsx

export const Example05 = () => {

const [text, setText] = useState("");

const deferredText = useDeferredValue(text, { timeoutMs: 10000 });

console.log(text, deferredText);

return (
<>
<h1>05 Example of useDeferredValue 2</h1>
<p>
<input value={text} onChange={e => setText(e.currentTarget.value)} />
</p>
<Show10000Times text={deferredText} />
</>
);
};

const Show10000Times: React.FC<{
text: string;
}> = memo(({ text }) => (
<p>
{Array.from({ length: 100 }).map((_, i) => (
<Show100Times text={text} />
))}
</p>
));

const Show100Times: React.FC<{
text: string;
}> = ({ text }) => (
<>
{Array.from({ length: 100 }).map((_, i) => (
<span key={i}>{text}</span>
))}
</>
);


新たに追加されたShow10000Timesというコンポーネントは、渡されたtextを一万回描画するコンポーネントです。その中身はShow100Timesが100個という構成です。実はこれは単に一万回ループしてspanを並べるよりもReactに都合が良い設定になっています(レンダリング処理を分割しやすいため)。

先ほどの例と同様にinput要素にテキストを入力してみると、下にすごく長い文字列が出現します。1万個のspan要素をレンダリングしなければならない関係でどうしても重くなりますね。

今回もconsole.logがあるので観察してみましょう。すると、先ほどとは違ってdeferredTextはtextよりもだいぶ遅れて付いてくることが分かります。Reactがレンダリングで忙しいためdeferredTextの更新が飛ばされるのです。


useDeferredValueの意義

useDeferredValueで値の変化の遅延を表現できることが分かりました。変化の遅延はいい感じに、もとい暇ができたら値が変化するという感じの挙動になります(正確な挙動を調べたわけではないのでありませんが)。

では、これは何の役に立つのでしょうか。その答えは、「画面の更新の一部を遅延したいとき」です。画面を更新するときに最優先で表示したい情報とそうでもない情報があるような場合にuseDeferredValueを使用できる可能性があります。

最優先で表示したい情報というのは、代表的なものはユーザーへのフィードバックです。これはuseTransitionのときも出てきたキーワードですね。特に上のサンプルのinput要素の場合、これはcontrolled componentであるためtextというステートの変更がすぐに反映されないとユーザーが違和感を感じてしまうため、何を差し置いても最優先で再レンダリングを完遂する必要があります。

一方で、下の<Show10000Times text={deferredText} />はそれよりも優先度が低い情報であり、input要素と同レベルの軽快さは求められません。そのためここでtextではなくdeferredTextを渡しています。これにより、textが更新された瞬間にShow10000Timesを再レンダリングする必要が無くなるのです。

実際にサンプルを動かしている方は、試しにこれを<Show10000Times text={text} />に変えてみましょう。すると、input要素の応答性がかなり悪くなるはずです。これはtextが変わるたびにこのコンポーネントを再レンダリングする必要があり、それが終わらないとinput要素も再レンダリングされないからです。

まとめると、この例ではuseDeferredTextを用いてShow10000Timesの再レンダリングを遅延させることで、input要素の反応速度を挙げているのです。

useTransitionのときと同様に、この問題も適切なステート管理によっても解決できるかもしれません。そんれにも関わらずReactがこの機能を用意した理由は、やはりレンダリングの一番適切なスケジューリングが出来るのはReact自身だからということに尽きるでしょう。


useDeferredValueが得意な状況と苦手な状況

実は、上の例はサンプルとして最善のものではありません。実際、input要素をいじっているとそこそこ頻繁に(Show10000Timesの再レンダリングが走るたびに)応答性が悪くなってしまいます。

これは、(まったく効果が無いわけではありませんが)今回のサンプルがuseDeferredValueの苦手な状況だからです。

具体的には、今回応答性が悪くなる原因はshow10000TimesのレンダリングのボトルネックがDOMへの反映だからです。ユーザーに中途半端な状態を見せないために、いわゆる仮想DOMを実際のDOMへと反映させる作業は一気に行う必要があります。そのため、せっかくReactがレンダリングを中断できる機能を実装したといっても、DOMへの反映は分割できないのです。まあ、だからこそ実際のDOM操作を最小限にすることが重要になってくるのですが。

逆に言えば、仮想DOMの構築段階が重い場合(単純にツリーが大きいとか、関数コンポーネントに重い処理があるなど)の場合はuseDeferredValueの得意な状況となります。仮想DOMレベルのレンダリングの段階ではReactがより上手にスケジューリングを行うことができるからです1。


useDeferredValueとuseTransitionの併用

ここまでで2つのフックを紹介しましたので、最後に2つを併用する例を紹介します。

特に、上のuseDeferredValueの例はメインスレッドを専有するレンダリング処理に対する対応策でしたが、実はuseDeferredValueはSuspenseと組み合わせてもいい感じに動きます。

今回の例はこれです。


06-useDeferredValue-example3.tsx(抜粋)

export const Example06 = () => {

const [text, setText] = useState("");
const [dataId, setDataId] = useState(0);
const [startTransition] = useTransition({ timeoutMs: 10000 });

const deferredDataId = useDeferredValue(dataId, { timeoutMs: 10000 });

return (
<>
<h1>06 Example of useDeferredValue 3</h1>
<p>
<input
value={text}
onChange={e => {
const newText = e.currentTarget.value;
setText(newText);
startTransition(() => {
setDataId(newText.length);
});
}}
/>
</p>
<Suspense fallback={<p>Loading...</p>}>
<p>データID: {dataId}</p>
<LoadedContents dataId={deferredDataId} />
</Suspense>
</>
);
};

const LoadedContents: React.FC<{ dataId: number }> = ({ dataId }) => {
const data = getData(dataId);
return <p>{data}</p>;
};


またSuspenseの話なので、データをロードして表示するコンポーネントLoadedContentsを用意しました。データを読み込むには時間がかかるため、このコンポーネントはサスペンドする可能性があります。

実際の動作としては、input要素に入力した文字列の文字数がデータIDとして表示され、さらに一定時間後にそのIDのデータが読みこまれて表示されます。useDeferredValueの効果により、データIDと実際に表示されるデータに差異が発生することがあります。下の例では、データIDが21なのに実際に表示されているデータは17番のものです。

例6のスクリーンショット


useDeferredValueとuseTransitionの併用時の挙動

中身の解説に移ります。メインのコンポーネント(Example06)のステートは二つで、ひとつはuseDeferredValueの例と同じくtext、もうひとつはdataIdです。dataIdは読み込みたいデータの番号です。dataIdはtextの文字数とします。

それならわざわざdataIdというステートを用意しなくてもconst dataId = text.length;でいいような気がしないでもないですが、今回そうできない理由があります。input要素のonChange属性を見ると分かりますが、textはstartTransitionの外で、dataIdは中で変更しています。この違いのためにステートを別々にする必要があったのです。

startTransitionの外で変更されているtextは即座に変更されます。これにより、input要素は常にユーザーの入力に対するフィードバックを素早く返します。

dataIdは<p>データID: {dataId}</p>というところで使われています。これはstartTransitionの中で変更されるため、新しいデータが読みこまれるまでは表示が更新されません……といいたいところですが、実はそうでもありません。上記のように、新しいデータが読みこまれる前にdataIdが更新されることがあります。

これはuseDeferredValueによるものです。LoadedContentsに渡しているのはdeferredDataIdであり、dataIdが更新された直後はまだdeferredDataIdが更新されていないためにサスペンドが発生しません。これにより、即座にdataIdの更新が画面に反映されるのです。ただ、useTransitionの効果により、一旦サスペンドが発生したらdataIdの更新が画面に反映されなくなります。これにより、dataIdが常にぴったりtextに追随するというわけでもなくなります。

ここで強調したいのはuseDeferredValueの効果です。サスペンドするコンポーネントに渡される値を遅延させることで変更直後のサスペンドを防ぐことができる可能性があり、その結果としてdataIdの変化が早めにユーザーに表示されるのです。

今回はあまり意味がない例に見えますが、複数のソースからデータを読み込むときに早いほうのデータを読み込めたらすぐに表示し、かつ遅いデータは読み込め次第表示する(それまでは古いデータを表示しておく)というような場合において役に立ちそうです。


useDeferredValueとSuspense

今回の例でも、useDeferredValueによるいくらかの遅延が発生していることが分かりました。

しかし、今回は前の例のようにメインスレッド的に重い処理があるわけではありません。代わりにあるのはサスペンドするコンポーネントです。

このことから、サスペンドするコンポーネントもuseDeferredValueに対するスケジューリングに影響を与えることが分かります。

正直に述べると、どんな時にどれくらい遅延するのかはよく分かりませんでした。例えば公式のサンプルではサスペンドしていたコンポーネントが一つレンダリング完了した時点で遅延更新が行われる挙動が確認できますが、なぜそのタイミングなのかは非自明です。恐らく、React側としてもここはヒューリスティクスの入る余地がある部分なので、あまり厳しい制約をかけたくないのであろうと思います。

今のところ唯一信頼できる性質は、元の値が変化した瞬間はuseDeferredValueの返り値はそれに追随しないというものだと思われます。つまり、必ず元の値(useDeferredValueの引数に渡された値)よりも古い値が返される瞬間が存在するようです。

個人的には、正式版リリースまでにはもう少し分かりやすくなってほしいなあと思わないでもありません。使い続けていればそのうち理解できるかもしれませんので、次の記事を期待せずにお待ちください。


所感

まず最初にPromiseをthrowすることについて述べておくと、なかなか面白いですね。非同期処理というのはどこか一箇所でも混ざりこむと処理の全体を非同期に対応するように書かないといけないわけですが、その対応をReactに丸投げ(文字通り)することで強引に解決し、ユーザーランドではまるで同期処理のように見える処理を書くことができます(関数型言語のユーザーたちは副作用の種類が変わっただけじゃんと言うかもしれませんが)。それが“良い”かどうかはさておき、React的な発想であると感じられました。

さて、この記事では2つの新しいフックを紹介しましたが、両方に共通するキーワードはユーザーエクスペリエンスでしたね。特に、望ましいフィードバックを最速で返すことに焦点が置かれています。もちろんそれは従来から我々が心血を注いできたものですが、今回React本体にそれを支援する機能が入ったことになります。

その背景は、一つはReact本体がやるべき仕事をReact本体がやるようにしたという単純な話です。しかしもう一つ、中央集権的ではなくコンポーネントベースな手法をさらに推進していく意思が感じられます。

個人的な所感としては、確かにその方向性で出来ることが広がったものの、まだ全部把握しきれる気がしないなあと感じています。色々なコンポーネントがステートを持っているだけでも大変なのにそれらがサスペンドしたりしなかったりするとなると、ロジックの全体像の把握は非常に困難になります。

Reactの思想としては、コンポーネントが成す木構造を中心に据えるという点ではとても一貫しています。木構造の上でロジックを適切に分割してやることで我々が考えなければいけないことを減らすという方向性が見て取れます。

ここから言えることは、Suspenseを始めとしたこれらの新機能を使いこなすにはそのための精巧な設計が必要であろうということです。従来中央集権的な方法で解決されてきた問題に対してReactはコンポーネントベースの新たなアプローチを与えました。次に我々がすべきことはこのアプローチを受け入れ咀嚼することです。

React自体が「Suspenseエコシステム」と呼んでいるように、Promiseをthrowするといった概念は、周辺ライブラリに新たな影響を与えます。これはちょうどHooksが登場したときのようです。

Reactはdata fetching libraryを主に見ているようですが、その流れで状態管理ライブラリにも新しい設計のものが出るのではないかと思います。この記事を読んでconcurrent modeを完全に理解したという方がいれば自分で作ってみてもいいかもしれません。

また、今回の新機能によってReactの非自明さが増したという印象を受けました。特にuseDeferredValueの挙動において顕著です。それに、useTransitionが絡んでくると何がいつ再レンダリングされるのか正直まだ予測が付きません。使っているうちに理解できるのか、それとも理解しようとしないとずっと非自明なままなのかはまだ定かではありません。

先にも述べたようにReactは結果の整合性を保ちつつレンダリング中の種々の保証を減らす方向に動いています。自分はこれまでReactはフレームワークじゃなくてライブラリだよという主張を続けてきましたが、何となくフレームワークっぽさが出てきたなあという気がしないでもありません。


まとめ

まず、記事の後半になるにつれて文章が読みにくくなっていったと思います。最初は真面目に記事を書いていたのですが後半疲れてきて、だんだんReactを触って脳内をダンプしただけみたいな文章になってしまいました。それでも何かの参考になれば幸いです。

ということで、10月25日に公開されたReact Concurrent Modeのプレビュー版を調査し、useTransitionとuseDeferredValueという2つの新しいフックについてまとめました。

useTransitionは画面遷移前と後の2つの状態をReactに同時に管理してもらうことができるフックでした。また、useDeferredValueは値がいい感じに遅延しながら変化するというフックでした。

これらがどんな問題を解決するのかと言えば、ユーザーエクスペリエンスの向上です。そのためにReactが供えているべき機能として上記のフックが追加されたわけです。

これが実現するには中断可能なレンダリングの機構が必要で、それが長い時間をかけて実装されたconcurrent modeです。

所感は上に述べた通りですが、まとめ直すとReactの非自明さが少し増したなという印象を受けました。筆者は7〜8時間くらいはReactのconcurrent modeをいじりましたが、まだ良くわかっていない点も多くあります。

理解を深めるにはもっと使い倒す必要があると感じていますが、React公式側からのさらなる解説もできれば期待したいところです。





  1. ただし、ひとつの関数コンポーネントの処理にものすごく時間がかかるというような状況には無力です。Reactでもさすがに関数の実行を中断させることはできないからです。それよりも、たくさんの関数コンポーネントがあって全部処理すると重いというような状況に対してより有効です。 ↩