この記事は「Concurrent Mode時代のReact設計論」シリーズの4番目の記事です。
シリーズ一覧
- Concurrent Mode時代のReact設計論 (1) Concurrent Modeにおける非同期処理
- Concurrent Mode時代のReact設計論 (2) useTransitionを活用する
- Concurrent Mode時代のReact設計論 (3) SuspenseやuseTransitionが何を解決するか
- Concurrent Mode時代のReact設計論 (4) コンポーネント設計にサスペンドを組み込む
- Concurrent Mode時代のReact設計論 (5) トランジションを軸に設計する
- Concurrent Mode時代のReact設計論 (6) Concurrent Modeと副作用
- Concurrent Mode時代のReact設計論 (7) ステート管理ライブラリの展望(仮)
- Concurrent Mode時代のReact設計論 (8) まとめ(仮)
コンポーネント設計にサスペンドを組み込む
前回の最後にrender-as-you-fetchという概念が出てきました。これは、ReactのConcurrent Modeのドキュメントにおいて提唱されているUXパターンであり、読み込んで表示すべきデータが複数ある場合に、全てが読み込み完了するまで待つのではなく読み込めたデータから順に表示するというものです。
このパターンの良し悪しはともかく、これはConcurrent Mode時代のコンポーネント設計を議論するための格好の題材です。
基本パターン: データごとにPromiseを分ける
Concurrent Modeにおいてrender-as-you-fetchを実現するには、それぞれのデータに対して異なるPromise(Fetcher)を用意する必要があります。そして、各データを担当するコンポーネントを用意して、それぞれのコンポーネントがサスペンドします。
そうすることで、それぞれのデータが用意できた段階でコンポーネントのサスペンドが解除(再レンダリング)され、その部分のデータが表示されます。
具体例として、ユーザーのリストを3種類読み込んでrender-as-you-fetch戦略で表示するコンポーネントを書いてみるとこんな感じです。
const PageB: FunctionComponent<{
dailyRankingFetcher: Fetcher<User[]>;
weeklyRankingFetcher: Fetcher<User[]>;
monthlyRankingFetcher: Fetcher<User[]>;
}> = ({ dailyRankingFetcher, weeklyRankingFetcher, monthlyRankingFetcher }) => {
return (
<>
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={dailyRankingFetcher} />
</Suspense>
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={weeklyRankingFetcher} />
</Suspense>
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={monthlyRankingFetcher} />
</Suspense>
</>
);
};
const Users: FunctionComponent<{
usersFetcher: Fetcher<User[]>;
}> = ({ usersFetcher }) => {
const users = usersFetcher.get();
return (
<ul>
{users.map(({ id, name }) => (
<li key={id}>{name}</li>
))}
</ul>
);
};
PageBは3種類のFetcher<User[]>を受け取ります。実際にFetcher<User[]>から得てデータを表示するのは別に用意したUsersコンポーネントが担当しており、PageBの役割は各Users要素をSuspenseで囲むことです。
ポイントは、このようにUsersをそれぞれSuspenseで囲まないといけないということです。復習すると、Suspenseの役割はその内部で発生したサスペンドをキャッチして、その場合にfallbackで指定されたフォールバックコンテンツを代わりにレンダリングすることです。Suspenseの中のどこでサスペンドが発生しようと、そのSuspenseの中身全体がフォールバックします。
このことから、Suspenseを用いてrender-as-you-fetchパターンを実装するには、あるコンポーネントがサスペンドしても他の部分に影響を与えないようにする必要があります。ここではSuspenseを複数並べることでこれを達成しています。
実際このPageBを適当なデータでレンダリングすると、下のスクリーンショットのように一つずつLoading users...がUsersによってレンダリングされたデータに置き換わっていく挙動をとります。
ちなみに、Suspenseの組み立て方によって色々な表示パターンを実現することができます。例えば、次のようにすると、dailyRankingFetcherが用意できるまでは何も表示せず、用意できたら残りを待つという挙動になります。
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={dailyRankingFetcher} />
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={weeklyRankingFetcher} />
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={monthlyRankingFetcher} />
</Suspense>
</Suspense>
</Suspense>
このように、コンポーネントが非同期処理の結果をどのように待ってどう表示するのかというロジックはSuspenseを用いて書くことができます。これはつまり、Concurrent Modeではrender-as-you-fetchパターンに必要なロジックを実際にそのデータを表示するコンポーネントが(内部でのコンポーネント分割は起こりますが)記述できるということを表しています。前回の記事で示した問題の一つがConcurrent Modeでは解決されているわけです。
おまけに、Suspenseという道具を用いて、データローディングに係るロジックをJSXというきわめて宣言的な表現により記述することができています。一応誤解がないように述べておくとJSXという構文は重要ではなく、本質的にこの点に寄与しているのはコンポーネントが成す木構造という表現方法なのですが。
なお、この立場に立つと、レンダリングのサスペンドというのはコンポーネントが発生させる現象ですから、サスペンドする役割を持つコンポーネント(今回はUsers)を明確にすることが重要になります。コンポーネントにdoc commentなどを書く際に、「このコンポーネントはいつサスペンドするのか」を明示しておくのもよいでしょう。
useTransitionとSuspenseの関係
まずuseTransitionについて復習します。このフックからはstartTransition関数を得ることができ、startTransitionの内部で発生したステート更新により再レンダリングが発生してそのレンダリングでサスペンドが発生した場合、サスペンドが解消されるまで画面に更新前のステートを表示し続けられるというものでした。
useTransitionが絡むと、Suspenseに係るコンポーネント設計はかなり複雑な様相をとります。これに関連して、ひとつ重要な事実を覚えていただく必要があります。
それは、再レンダリング時に新たにマウントされたSuspenseの中で起きたサスペンドはuseTransitionからは無視されるという点です。言い方を変えれば、useTransitionの効果を発動するには、あらかじめ用意してあったSuspenceにサスペンドをキャッチしてもらう必要があるということです。
この挙動はバグなのではと筆者は一瞬思いましたが、このissueで説明されている通りこれは仕様です。
コンポーネントを設計する際にはこのことを念頭に考える必要があります。すなわち、あるコンポーネントの中でサスペンドを発生させるにあたり、それがuseTransitionに対応するサスペンド(外部のSuspenseによりキャッチされることを意図したサスペンド)なのか、それともuseTransitionに対応しないサスペンド(自身の中で新たに生成したSuspenseによりキャッチされるサスペンド)なのかを意識的に区別しなければならないということです。
では、先ほど出てきたPageBの場合はどうでしょうか。
const PageB: FunctionComponent<{
dailyRankingFetcher: Fetcher<User[]>;
weeklyRankingFetcher: Fetcher<User[]>;
monthlyRankingFetcher: Fetcher<User[]>;
}> = ({ dailyRankingFetcher, weeklyRankingFetcher, monthlyRankingFetcher }) => {
return (
<>
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={dailyRankingFetcher} />
</Suspense>
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={weeklyRankingFetcher} />
</Suspense>
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={monthlyRankingFetcher} />
</Suspense>
</>
);
};
startTransition中のステート更新で新たにPageBコンポーネントがマウントされた場合、3つのSuspenseコンポーネントがマウントされ、その中で発生したサスペンドは即座にキャッチされます。このとき、ステート更新によって発生したサスペンドは全て新たにマウントされたSuspenseによってキャッチされることになります。
よって、このステート更新ではuseTransitionの効果は発揮されません。ステート更新が行われた瞬間にPageBがレンダリングされDOMに反映されます。PageBは最初3つのLoading users...を表示することになるでしょう。実際、このPageBに前回の記事で出てきたRootとPageAを繋げてみるとそのような挙動になります。興味がある方は実際にやってみましょう。
useTransitionのための応用的なコンポーネントデザイン
このことを踏まえて、PageBをuseTransitionに対応するように改良するにはどうすればよいか考えてみましょう。もし「全部ロードされるまで前の画面を表示し続けたい」という場合は話は簡単で、それぞれのUsersコンポーネントをSuspenseで囲むのをやめればよいです。また、例えば「dailyRankingFetcherがロード完了するまでは前の画面を表示し続けたい」のような場合も、対応するUsersだけSuspenseで囲まなければ対応できます。
厄介なのは、「どれか1つのデータが読み込めるまでは前の画面を表示し続けたい」というような場合です。この場合はただSuspenseを消すだけでは達成できません。
Promiseの機能を思い出すと、「どれか1つのPromiseが解決するまで待つ」という挙動はPromise.raceにより達成できます。ということで、今回はFetcher.raceを用意すれば解決できますね。
Fetcher.raceの実装を用意するとこんな感じです(コンストラクタをあのインターフェースにしたので実装がひどいことになっていますがサンプルだと思って大目に見てください)。
static race<T extends Fetcher<any>[]>(
fetchers: T
): Fetcher<FetcherValue<T[number]>> {
for (const f of fetchers) {
if (f.state.state === "fulfilled") {
const result = new Fetcher<any>(() => Promise.resolve());
result.state = {
state: "fulfilled",
value: f.state.value
};
return result;
} else if (f.state.state === "rejected") {
const result = new Fetcher<any>(() => Promise.resolve());
result.state = {
state: "rejected",
error: f.state.error
};
}
}
return new Fetcher(() =>
Promise.race(fetchers.map(f => (f as any).promise))
);
}
ちなみに、型に出てきたFetcherValueはこのように定義しています。型安全な実装が厳しい場合でも、型パズルでも何でも駆使して関数のインターフェースだけは正確さを守るというのが堅牢なTypeScriptプログラムを書くコツです。
type FetcherValue<F> = F extends Fetcher<infer T> ? T : unknown;
話を元に戻すと、このFetcher.raceを使ってPageBをこのように定義すれば、「どれか1つのデータが来るまでサスペンドする」という挙動が実現できます。Fetcher.race([...])はgetメソッドを使用するためだけに作られており、その値はPageB直下では使われていません。このように、値を得ることではなくサスペンドすることが主目的のFetcherというのも存在し得ます。少し話が違いますが、筆者も第1回の記事で紹介したアプリケーションではFetcher<void>を多用しています。
const PageB: FunctionComponent<{
dailyRankingFetcher: Fetcher<User[]>;
weeklyRankingFetcher: Fetcher<User[]>;
monthlyRankingFetcher: Fetcher<User[]>;
}> = ({ dailyRankingFetcher, weeklyRankingFetcher, monthlyRankingFetcher }) => {
Fetcher.race([
dailyRankingFetcher,
weeklyRankingFetcher,
monthlyRankingFetcher
]).get();
return (
<>
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={dailyRankingFetcher} />
</Suspense>
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={weeklyRankingFetcher} />
</Suspense>
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={monthlyRankingFetcher} />
</Suspense>
</>
);
};
再レンダリング時のサスペンド設計
ここからは、一旦最初のPageBに頭を戻して考えます。
const PageB: FunctionComponent<{
dailyRankingFetcher: Fetcher<User[]>;
weeklyRankingFetcher: Fetcher<User[]>;
monthlyRankingFetcher: Fetcher<User[]>;
}> = ({ dailyRankingFetcher, weeklyRankingFetcher, monthlyRankingFetcher }) => {
return (
<>
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={dailyRankingFetcher} />
</Suspense>
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={weeklyRankingFetcher} />
</Suspense>
<Suspense fallback={<p>Loading users...</p>}>
<Users usersFetcher={monthlyRankingFetcher} />
</Suspense>
</>
);
};
これまでの議論ではPageBが新規にマウントされた場合を考えていました。このときは中身のSuspenseが新規にマウントされるので、その中のUsersがサスペンドしてもuseTransitionが反応しないのでした。
では、PageBがすでにマウントされている状態で、startTransitionの中のステート更新に起因してPageBのpropsが変わった場合はどうでしょうか。新しくpropsから渡されたFetcherによってサスペンドした場合、それをキャッチするのはPageBがレンダリングしたSuspenseであることに変わりませんが、今回はこれらのSuspenseはあらかじめマウントしてあったSuspenseです。なぜなら、前回のPageBのレンダリングによってこのSuspenseはすでにマウントされていたからです。よって、この場合はuseTransitionが働きます。
つまり、PageBは「新しくマウントされたときはuseTransitionに非対応だが、マウント済の状態でpropsが更新された時はuseTransitionに対応」という特徴を持つコンポーネントなのです。Concurrent Modeによほど精通していなければ、コンポーネントの定義を一目見てこのことを見抜くのは難しいでしょう。
この状態はなんだか一貫性がありませんね。これでも良いならばこの実装で問題ありませんが、どちらかに統一したいということもあるでしょう。まず、常にuseTransitionに対応にしたい場合の方法は先ほどまで述べた通りで、Suspenseを消すなり、Suspenseの中ではなくPageB自体がサスペンドするなりといった方法があります。
一方、常にuseTransition非対応にしたい場合はどうすれば良いでしょうか。答えは、「propsが変わるたびにSuspenseをマウントし直す」です。そうすることでSuspenseは常に新しくマウントされた扱いとなり、その中でのサスペンドはuseTransitionに影響しなくなります。Suspenseをマウントし直すにはkeyを用います。Reactでは、同じコンポーネントでも異なるkeyが与えられた場合は別のコンポーネントと見なされますから、propsが変わるたびにSuspenseに与えるkeyを変えることで、Suspenseをアンマウント→マウントさせることができます。
具体的な方法の一例としては、まず次のようなuseObjectIdカスタムフックを用意します。
export const useObjectId = () => {
const nextId = useRef(0);
const mapRef = useRef<WeakMap<object, number>>();
return (obj: object) => {
const map = mapRef.current || (mapRef.current = new WeakMap());
const objId = map.get(obj);
if (objId === undefined) {
map.set(obj, nextId.current);
return nextId.current++;
}
return objId;
};
};
このフックはPageBの中で次のように使います。今回のコードではそれぞれのSuspenseにkeyが与えられており、keyを計算するためにuseObjectIdを使用しています。
const PageB: FunctionComponent<{
dailyRankingFetcher: Fetcher<User[]>;
weeklyRankingFetcher: Fetcher<User[]>;
monthlyRankingFetcher: Fetcher<User[]>;
}> = ({ dailyRankingFetcher, weeklyRankingFetcher, monthlyRankingFetcher }) => {
const getObjectId = useObjectId();
return (
<>
<Suspense
key={`${getObjectId(dailyRankingFetcher)}-1`}
fallback={<p>Loading users...</p>}
>
<Users usersFetcher={dailyRankingFetcher} />
</Suspense>
<Suspense
key={`${getObjectId(weeklyRankingFetcher)}-2`}
fallback={<p>Loading users...</p>}
>
<Users usersFetcher={weeklyRankingFetcher} />
</Suspense>
<Suspense
key={`${getObjectId(monthlyRankingFetcher)}-3`}
fallback={<p>Loading users...</p>}
>
<Users usersFetcher={monthlyRankingFetcher} />
</Suspense>
</>
);
};
useObjectIdフックは関数getObjectIdを返します。この関数は各オブジェクトに対して異なるIDを返します。同じオブジェクトに対しては何回呼んでも同じIDが返されます。これをkeyに組み込むことによって、daylyRankingFetcherなどに別のFetcherが渡されたタイミングでSuspenseに渡されるkeyも更新され、新たなSuspenseがマウントされた扱いになります。
この実装により、PageBが別のpropsで再レンダリングされた場合でも、内部で発生するサスペンドの影響を内部に封じ込めてuseTransitionに影響させないことが可能になりました。
SuspenseとuseTransitionの関係を整理する
ここまでは、SuspenseとuseTransitionの関係を解説し、ユースケースに合わせた実装法を紹介してきました。なんだか場当たり的な印象を受けた読者の方も多いと思いますので、もう少し整理して見直してみましょう。
あるコンポーネントがPromise(をラップするFetcher)を受け取るとします。そのコンポーネントの責務がそのデータを表示することであれば、必然的にそのコンポーネントはサスペンドを発生させることになります。
コンポーネント内で発生しうるサスペンドは3種類に分類できます。3種類のサスペンドは、「コンポーネントの外にサスペンドが出て行くかどうか」と「useTransitionに対応するかどうか」に注目すると次の表のようにそれぞれ異なる特徴を持ちます。
| サスペンドの種類 | 外に出て行くか |
useTransition対応 |
|
|---|---|---|---|
| 1 | 内部のSuspenseでキャッチされないサスペンド |
Yes | Yes1 |
| 2 | 内部で新規にマウントされたSuspenseにキャッチされるサスペンド |
No | No |
| 3 | 内部の既存のSuspenseにキャッチされるサスペンド |
No | Yes |
パターン1が一番スタンダートなサスペンドでしょう。あるコンポーネントがFetcherから得たデータを表示することが責務ならば、データがまだない場合にそのコンポーネントがサスペンドするのは自然なことです。
パターン2は逆にサスペンドを完全に内部で抑え込むパターンです。サスペンドが発生しても、そのことはコンポーネントの外部には検知されません。データがまだ無いときの挙動を完全にコンポーネント内で制御したい場合に適しています。
パターン3は、コンポーネントが新規にマウントされた場合は発生せず、再レンダリングのときのみ可能な選択肢です。これは扱うのがやや難しいですが、コンポーネントの内部でuseTransitionを使いたい場合などはこれが一番自然な選択肢となることが多いでしょう。
コンポーネントのロジックを実装する際には、これらを組み合わせることもあるでしょう。例えば、先ほど出てきたFetcher.raceの例は1と2の合わせ技です。
コンポーネントの使い勝手という観点からは、パターン1が最も有利です。パターン1はコンポーネントの外側にSuspenseを配置すれば2や3に変換できますが、逆に2や3を1に変換することはできないからです。
パターン1と2や3の使い分けはコンポーネントの責務に応じて決めるのが良いでしょう。具体的には、データがない場合にフォールバックを表示するという責務をコンポーネントが持っているのであれば、2か3を選択することになります。逆に、その責務を持たずデータがない場合はサスペンドすべきならば、1を選択しなければなりません。
誰がFetcherを用意するのか
Concurrent Modeにおいては、誰かいつ非同期処理を開始する(Fetcherを用意する)のかがとても重要です。従来の基本的なパターンは、データを表示する責務を持ったコンポーネントがuseEffectの中で非同期処理を開始するというものです。Fetcherと組み合わせればこのような実装になるでしょう。
const PageB: FunctionComponent = () => {
const [dailyRankingFetcher, setDailyRankingFetcher] = useState<
Fetcher<User[]> | undefined
>(undefined);
useEffect(() => {
setDailyRankingFetcher(new Fetcher(() => fetchUsers()));
}, []);
return dailyRankingFetcher !== undefined ? (
<Users usersFetcher={dailyRankingFetcher} />
) : null;
};
しかし、2つの理由からこの実装は忌避すべきです。一つ目の理由は、一度レンダリングされたあとuseEffect内ですぐに再度レンダリングを発生させていることです。これはReactにおける典型的なアンチパターンの一つです。
もう一つの理由は、こうするとPageBが自動的にuseTransitionに非対応になるからです。PageBが最初にレンダリングされたときはまだサスペンドが発生しませんから、PageBに遷移するきっかけとなったステート更新ではサスペンドが発生しなかったことになります。もしPageBに遷移するときにuseTransitionを使いたければ、このような実装は必然的に選択肢から除外されます。
では、どうすればよいのでしょうか。大きく分けて2つの選択肢があります。基本的には、これまでやってきたように外からFetcherを渡すことになります。これについては次回の記事で詳しく扱います。
もう一つ、useEffectの中ではなく最初のレンダリング中に直にFetcherを用意するという戦略を思いついた方もいるかもしれません。しかし、ほとんどの場合これは無理筋です。
useStateでFetcherを用意することはできない
例えば、次のような実装を試してみましょう。useStateは関数を渡すと最初のレンダリング時にその関数が呼び出されてステートの初期化に用いられます。次のようにすることでdailyRankingFetcherをいきなりFetcherで初期化し、初手でサスペンドを発生させることができます。
const PageB: FunctionComponent = () => {
const [dailyRankingFetcher] = useState(() => new Fetcher(() => fetchUsers()));
return <Users usersFetcher={dailyRankingFetcher} />;
};
しかし、これは期待通りに動きません。PageBはずっとサスペンドしたままになります。その理由は、PageBがレンダリングされるたびに新しいFetcherインスタンスが生成されるからです。
PageBが最初にレンダリングされた場合はuseStateに渡された関数が呼ばれて新しいFetcherインスタンスがdailyRankingFetcherに入ります。ここまでは想定通りですが、その後サスペンド明けにPageBが再度レンダリングされたとき、PageBは初回レンダリングという扱いになります。よって、dailyRankingFetcherに入るのはまた新しく作られたFetcherインスタンスとなり、PageBは再度サスペンドします。これを繰り返すことになり、PageBは永遠に内容をレンダリングすることができません。
すなわち、レンダリングの結果サスペンドが発生したときはレンダリングが完了したと見なされず、useStateフックなどの内容はセーブされません。あたかも、そのレンダリングが無かったかのように扱われます。useMemoなども同じです。
この性質により、「最初にサスペンドしたレンダリング」から「サスペンド明けのレンダリング」に情報を渡すことは自力では困難です。そのため、最初のレンダリングの中で作ったFetcherインスタンスをサスペンド明けのレンダリングで手に入れることができず、サスペンドが空けても何をレンダリングすればいいか分からなくなってしまいます。Fetcherをpropsで外から受け取ることでこの問題は回避できるのです。
サスペンドとコンポーネントの純粋性
useStateがだめならuseRefなら、と思った方もいるかもしれませんが、実はuseRefでも無理です。useRefはレンダリングをまたいで同じオブジェクトを返すのが特徴でしたが、useRefによって返されるオブジェクトは最初のレンダリングで作られます。よって、「最初のレンダリング」が何回も繰り返されれば毎回新しいrefオブジェクトが作られることになり、やはりサスペンド前後の情報の受け渡しは困難です。
ただし、最初のレンダリング以外の場合は注意が必要です。そもそも最初のレンダリング以外であっても、サスペンドしたレンダリングの結果は残りません。例えば、useMemoはサスペンドしたレンダリングにおいて計算された値はキャッシュしません。そのレンダリング中に値を計算したという事実が無かったことにされるからです。
しかし、useRefは「毎回同じオブジェクトを返す」のが役割ですから、初回以外であればサスペンドしたレンダリングとサスペンド明けのレンダリングではuseRefから同じオブジェクトが返されます。これを用いることで、サスペンドしたレンダリングから何らかの情報を残すことができます。
明らかに、このようなことは避けるべきです。それは、このようなuseRefの使用はレンダリングの純粋性を破壊しているからです。レンダリングの純粋性とは、「コンポーネントをレンダリングしても副作用が発生しない」という意味で、「意味もなくコンポーネントをレンダリングしても(=関数コンポーネントを関数として呼び出しても)安全である」という意味でもあります。
Concurrent Modeにおいては「コンポーネントがレンダリングされた(関数コンポーネントとして呼び出された)」ことは「そのコンポーネントのレンダリング結果がDOMに反映される」ことを意味しません。サスペンドが発生する可能性があるからです。この状況下でReactが好き勝手にレンダリングを試みるための前提として、コンポーネントは純粋であるべきとされているのです。
実際、Reactでは副作用はuseEffect内で行うように推奨しています。useEffectはコンポーネントが実際にDOMにマウントされた場合にコールバックが呼び出されます。サスペンドによりDOMに反映されなかった場合はコールバックは発生しません。
また、レンダリングが純粋であることを強調するためか、Conncurrent Modeではデフォルトで1回のレンダリングで関数コンポーネントが2回呼び出されるようになっています(おそらくproductionでは1回)。これは、純粋でないコンポーネントを作ってしまった際に発生するバグを検出しやすくするためでしょう。実は先ほどのuseStateのサンプルでも、1回PageBがレンダリングされるたびにFetcherインスタンスが2個作られていました。非同期処理を発生させるのも副作用ですから、そもそもuseStateのステート初期化時にFetcherインスタンスを作るのは無理筋だったということになります。
useRefに話を戻しますが、Concurrent Modeではrefオブジェクトへのアクセス(特に書き込み)は副作用であると考えるべきです。先ほど説明したように、レンダリング中にrefオブジェクトに書き込むと、サスペンドしたレンダリングの影響がそれ以降に残ってしまうため、コンポーネントのレンダリングが純粋でなくなるからです。refオブジェクトは、useEffectのコールバック内やイベントハンドラなど、副作用が許された世界でのみアクセスすべきです。refオブジェクトはもはや完全に副作用の世界の住人なのです。
目ざとい方は、先程出てきたuseObjectIdはuseRefに書き込んでいたじゃないかと思われるかもしれません。それはそのとおりなのですが、実はuseObjectIdはレンダリングの純粋性を損なわないように注意深く実装されています。純粋性を壊さない注意深い実装ならば、useRefを使える可能性もあるのです。無理なときは無理なので無理だと思ったら潔く諦めるべきですが。
まとめ
この記事では、サスペンドを念頭に置いたコンポーネント設計をどのようにすべきかについて議論しました。
重要なのは、サスペンドはその発生の仕方によって3種類に分類できるということです。さらに、これらを組み合わせることでより複雑なパターンを実装することもできます。もちろん、コンポーネントの記述は宣言的な書き方が保たれています。
Concurrent Modeでは、あるコンポーネントがどのような状況下でどの種類のサスペンドを発生させるのかということをコンポーネント仕様の一部として考えなければなりません。これは特にuseTransitionと組み合わせるときに重要です。Concurrent Mode時代のコンポーネント設計では、コンポーネントの責務は何なのかということを冷静に見極めて、そのコンポーネントはどのようにサスペンドすべきかということを考えなければならないのです。
記事の後半では、Concurrent Modeでは特にレンダリングの純粋性が重要であることを開設しました。これを踏まえると、初手でサスペンドするコンポーネントは必然的にFetcherを外部から受け取ることになります。
次回の記事では、誰がFetcherを作ってどう受け渡すのかについて考えていきます。
次の記事: Concurrent Mode時代のReact設計論 (5) トランジションを軸に設計する
-
このコンポーネントの外部に設置された既存の
Suspenseにキャッチ場合はuseTransitionに反応しないサスペンドとなりますが、それはこのコンポーネントの預かり知るところではありません。このコンポーネントがuseTransitionに対応する可能j性を消しているわけではないことからYesとしています。 ↩
