この記事は「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としています。 ↩