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秒後、「外部から読み込んだ何らかのデータ」という文字列が表示されます。
コードを前半と後半に分けて示します。
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
は追加コンテンツを表示するコンポーネントなのですが、追加コンテンツはボタンを押してからダウンロードする必要があるということにしましょう。もちろんダウンロードには時間がかかります。このコンポーネントは以下のように作りました。
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が解決されたらもう一回試してくれ〜」という意思表示のためです。
今回の場合、getData
はまだデータが読みこまれていない場合、「データの読み込みが完了したら解決されるPromise
」を投げます。これにより、データが読みこまれたらもう一度コンポーネントがレンダリングされることが期待されます。そのとき再度getData
が呼ばれますが、その場合はすでにデータがあるため再度Promiseをthrowする必要はありません。
Suspense
によるフォールバック
さて、コンポーネントをレンダリングしようとしたらPromiseを投げられてしまいました。これではレンダリング結果が存在しないのでコンポーネントをレンダリングできません。
これに対応するのがまさにSuspense
コンポーネントの役割です。コンポーネントのレンダリングがサスペンドした場合、これを囲むSuspense
の中身全体がフォールバックUI(Suspense
にfallback={...}
で渡したもの)に切り替わります。
ボタンを押した瞬間に「Loading...」という表示が出たのはこのためです。ここでは、以下のことが起こっています。
- ボタンを押したことで
setShowChild(true)
が実行され、showChild
がtrue
になる。 -
Example01
が再レンダリングされ、それに伴って<AdditionalContents />
がレンダリングされる。 -
AdditionalContents
のレンダリングがサスペンドする(Promiseが投げられる)。 -
Suspense
がこれに反応してfallback
の内容がレンダリングされる。 - 「Loading...」と表示される。
その後は、loadData(0)
が2秒で解決されるようになっているので(具体的な実装はサンプルのリポジトリをご覧ください)2秒後に<AdditionalContents />
が再びレンダリングされます。今回はサスペンドしないため、Suspense
のフォールバックは消えて中身がちゃんとレンダリングされます。
以上がSuspense
の基本機能ですが、読者の中には「こんなの【ここに好きな状態管理ライブラリを入れよう】があればできるでしょ😆Promiseをthrowするなんて意味不明なことする意味なくない?😅」と思った方もいるでしょう。何かisLoading
みたいなステートを用意すれば同じことができますよね。
それに対する答えは、これから説明するフックを通じて明らかになります。
では、いよいよ本題に入りましょう。
useTransition
useTransition
フックは、冒頭で述べたように今の画面を残しつつ次の画面を裏でレンダリングすることを可能にするフックです。今の画面を残すというのは、DOMが今の状態を維持するということです。今回用意した例はこちらです。
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
内部でステートが更新された場合、そのステート更新によって引き起こされた再レンダリングがサスペンドした場合はフォールバックを表示する代わりに今の状態を表示し続けます。
つまり、今回のサンプルの動作は以下のように説明できます。
- ボタンを押したことで
setShowChild(true)
が実行され、showChild
がtrue
になる。 -
Example02
が再レンダリングされ、それに伴って<AdditionalContents />
がレンダリングされる。 -
AdditionalContents
のレンダリングがサスペンドする(Promiseが投げられる)。 - サスペンドが発生したため、
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}
という属性を追加しました。
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
になった状態です。
初期状態は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
は値の更新をいい感じに遅らせてくれるフックです。例を見ましょう。
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に重い仕事をさせればいいことになります。とりあえずレンダリング量を増やしてみます。
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
と組み合わせてもいい感じに動きます。
今回の例はこれです。
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番のものです。
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公式側からのさらなる解説もできれば期待したいところです。
-
ただし、ひとつの関数コンポーネントの処理にものすごく時間がかかるというような状況には無力です。Reactでもさすがに関数の実行を中断させることはできないからです。それよりも、たくさんの関数コンポーネントがあって全部処理すると重いというような状況に対してより有効です。 ↩