Reactの実験的APIであるActivityとJotaiでの非同期処理についての調査
先日、React Labsから実験的APIであるActivity(旧Offscreen)に関するブログが投稿されました。
今回は、Activityでの事前レンダリング(pre-rendering)に関して、Jotaiとの組み合わせによる非同期処理に着目したいと思います。
Activityとは?
詳しくは公式ブログ記事とドキュメントに任せるとして、軽く内容に触れつつ本題に入ります。
主な特徴
これまで、コンポーネントの表示/非表示をステートによって切り替えるには、基本的にはコンポーネントのマウント/アンマウントによって制御してきました。
{isVisible && <Page />}
isVisible === true
である限り、Pageコンポーネントがマウントされた時から保持されたステートは生き続けます。しかし、isVisible === false
となった瞬間、Pageコンポーネントはアンマウントされることになり、持っていたステートは破棄され、再度マウントされたときは初期値でステートを作り直すことになります。
今回紹介されたActivity APIでは、mode propを使うことで表示/非表示を切り替えることができ、なんと非表示中のコンポーネントのステートを保持し続けることが可能となります。
<Activity mode={isVisible ? 'visible' : 'hidden'}>
<Page />
</Activity>
visible -> hidden -> visible ...
(表示 -> 非表示 -> 表示 ...) と変化しても、Pageコンポーネントのステートは破棄されないため、例えば検索などのフォームを持つコンポーネントは表示が切り替わっても内容を維持することができ、容易にアプリの体験を向上させることが可能となります。
データ取得とhidden mode
公式ブログではこの箇所の事です。
Activityで扱うコンポーネントがデータ取得をし、初回はhiddenで非表示である場合、Suspense fallbackの表示なしにデータ取得を実行することが可能になるのです。
通常、何かの仕組みを用意しない限りはマウント時にデータ取得が実行されるため、データ取得中はfallbackが必ず表示されることになります。(いわゆるfetch-on-renderと呼ばれるもの)
例えば以下のような場合、Detailsコンポーネントでデータ取得があると、遷移時にloading...
が表示されます。
<Suspense fallback="loading...">
{url === '/' && <Home />}
{url === '/details' && <Details />}
</Suspense>
これを、Activityを使うと以下のように書くことができ、遷移時にloading...
は出ないようになります。
<Suspense fallback="loading...">
<Activity mode={url === '/' ? 'visible' : 'hidden'}>
<Home />
</Activity>
<Activity mode={url === '/details' ? 'visible' : 'hidden'}>
<Details />
</Activity>
</Suspense>
pre-fetch, pre-renderingが簡単に実現できるようになりUXの向上が見込めますが、実践的に使うとなると再取得/再検証あたりをどう実現するかが気になります。
上の例だと、裏で一度データ取得を行ってレンダリングされたDetailsコンポーネントの新しいデータでのレンダリングは、何かの仕組みを用意してあげる必要があります。
visible -> hidden -> visible -> ...と変わるとして、2回目以降のhidden時に同様に"loading..."を出さずに済むにはどうしたらいいんでしょうか。
本記事のモチベーションはこの部分になります。
注意:Effectは対象外
上記では、データ取得に関して(しれっと)use(promise) + <Suspense />
が前提として紹介されています。
Effects don’t mount when an Activity is hidden.
ブログ記事ではNoteセクションで軽く紹介されていますが、<Activity />
は、hidden時にuseEffectをマウントしないため、useEffectでfetchする方法ではpre-renderingすることは出来ないので注意が必要です。
ドキュメントではこちらに詳細が記述されています。
Jotaiとの組み合わせ
Jotaiでの非同期処理
Jotaiでの非同期処理は、use(promise) + <Suspense />
をサポートして実装されています。
一般的には、async atomと呼ばれる以下のような書き方がよく紹介されます。
const listAtom = atom(async () => {
const list = await fetchList();
return list;
});
const Component = () => {
const list = useAtomValue(listAtom);
return <>{list.map((item) => ... )}</>
}
const App = () => (
<Suspense fallback="loading...">
<Component />
</Suspense>
)
上記のように、async atomはread-onlyの形式(atom関数の第一引数がプリミティブな値ではなく関数を受け取る)で宣言することで実現でき、atomをマウントすると値を解決する処理が走るので、この場合は<App />がマウントされるとlistAtomのasync関数が実行、"loading..."表示、promise解決後にlistの内容が表示されます。
Activity hiddenでちゃんと動きそう?
初回はActivity hiddenで非表示にしておき、ボタン押下後にComponentを表示するようにしてみました。
const listAtom = atom(async () => {
const list = await fetchList();
return list;
});
const Component = () => {
const list = useAtomValue(listAtom);
return <>{list.map((item) => ... )}</>
}
const App = () => {
const [isVisible, setIsVisible] = useState(false);
return (
<Suspense fallback="loading...">
<button onClick={() => setIsVisible(true)}>show</button>
<Activity mode={isVisible ? 'visible' : 'hidden'}>
<Component />
</Activity>
</Suspense>
)
}
やりました、これは、期待通りに動きます。(=ボタン押下後に"loading..."は表示されず、すぐさまlistの内容が表示されます)(ここでは動くコードは割愛)
JotaiはuseEffectを使っていないの・・・?
上記の動作だけを見てから予想すると、Jotaiが内部でuseEffectを使っていないように思えますが、そうではありません。
Jotaiには、派生atom(derived atom)と呼ばれるatomの種類があります。
const priceAtom = atom(100);
const taxAtom = atom(0.1);
const taxIncludedPriceAtom = atom((get) => get(priceAtom) * (1 + get(taxAtom)));
const Component = () => {
const taxIncludedPrice = useAtomValue(taxIncludedPriceAtom);
...
}
read-only atomのtaxIncludedPriceAtomがそれになります。(priceAtomとtaxAtomはbase atomと呼ばれます)
priceAtomもしくはtaxAtomが変化すると、useAtomValue(taxIncludedPriceAtom)
はuseEffectでそれらの変化を再検証することで結果を得ます。
ということは、以下のようなasync atomは派生atomの形になるため、base atomであるparamsAtomが変化するとuseEffectが使われることになるので、Activityとの組み合わせはうまくいかないように思えます。
(私はこのパターンの書き方を多用してきたので、データの再取得/再検証をActivityと組み合わせて使えない不安がよぎります)
const paramsAtom = atom({ id: 1 });
const dataAtom = atom(async (get) => {
const params = get(paramsAtom)
const data = await fetchData(params);
return data;
});
実験です。以下のようなものを用意してみました。
リロード後に3秒経過したあと、showボタンを押すと"loading..."の表示無しにComponentが表示されます。これはうまく言っているように見えます。(3秒以内にshowボタンを押すと"loading..."は表示されますね)
では次に、リロードし3秒経過した後にupdateボタンを押して、3秒以上充分に待ってからshowボタンを押してみてください。
結果は、"loading..."が表示されます。
😇(やっぱりそうなったか。もしかして詰んだ?)
Activityのhidden modeでは、useEffectはマウントされないため、base atomであるparamsAtomが変化したことをuseAtomValue(dataAtom)
は知ることが出来きず、Activityがvisible modeになったタイミングでようやくpromiseを解決する処理が走ります。
解決策
問題は、derivedなread-only async atomであることです。他のatomに依存せず、プリミティブな値で定義されるatomそのものはuseEffectとは関係なくなります。どうにかしてこれらを実現します。
atomはpromiseを持つことが可能・・・💡
実は、atomはpromiseを値として持つことが可能です。
useAtomValueの実装にて、return use(promise)
として値を扱っているのが見られます。
例えば以下のような形でatomを定義できるため、read-only async atomでなくとも、非同期処理が実行可能になります。
const request = async () => { ... } // () => Promise<Data>
const baseAtom = atom(request()) // atomはPromise<Data>を初期値に持つ
ドキュメントでは、Async Sometimes
で紹介されています。
このパターンで先ほどのコードを以下のように変更する事ができます。
+ const initParams = { id: 1 };
+ const requestData = async (id: number) => {
+ await new Promise((r) => setTimeout(() => r(true), 3000));
+ return `Data with id: ${id}`;
+ };
+ const dataAtom = atom(requestData(initParams.id));
- const dataAtom = atom(async (get) => {
- const params = get(paramsAtom);
- await new Promise((r) => setTimeout(() => r(true), 3000));
- return `Data with id: ${params.id}`;
- });
さて、残すは引数のidを変化させてrequestDataで再取得させることができれば目的達成です。
任意にdataAtomへpromiseをセット
とは言うものの、後は単純にdataAtomに対して任意のタイミングでrequestData(newId)をセットすれば良さそうです。
出来上がりがこちらになります。最初に提示したread-only async atomでなくとも動作を再現することに成功し、さらに問題だったupdate押下後のvisibleへの変更でSuspense fallbackが表示されてしまうことも無くなりました。
※ 便宜上paramsAtomも更新していますが、今回の例では無くても問題ありません
もう完璧になった?
ここまでは、(hidden) -> visible
の変化のみで求める挙動を模索してきました。
最後に、(hidden) -> visible -> hidden -> visible
を試します。
手順:「(hiddenから)一度showボタン押下(visibleへ)、hideボタン押下(hiddenへ)。updateボタン押下後、3秒以上待ち、showボタン押下(visibleへ)」
あれ?3秒とかではなく、一瞬だけ"loading..."が表示される??
この挙動は期待していませんでした。初回マウント時の挙動と、マウント後の挙動に差異があるんだろうな程度に想像はできますが、理由がわからないため、今後調べておきます。
最終調整
ひとまず期待通りの挙動を実現する方法を2つ紹介したいと思います。
データ取得ごとに対象のコンポーネントのkeyを変化させる
1つ目は、<Activity />
もしくは <Component />
に対してkeyを与えることです。
変更点は以下の通りです。
これまでの挙動を再現しつつ、期待通りになりました。
const updateAtom = atom(
- null,
+ (get) => get(paramsAtom).id,
(get, set) => {
set(paramsAtom, (c) => ({ ...c, id: c.id + 1 }));
set(dataAtom, requestData(get(paramsAtom).id));
}
);
- const update = useSetAtom(updateAtom);
+ const [key, update] = useAtom(updateAtom);
- <Activity mode={isVisible ? 'visible' : 'hidden'}>
+ <Activity key={key} mode={isVisible ? 'visible' : 'hidden'}>
Promise<string> | string
の値を持てるようにする方法
dataAtomを2つ目は、公式に紹介のあるAsync Sometimesのパターンにしてしまう方法です。
変更点は以下の通りです。
atomのwrite関数はasync関数となれるので、もうここでawaitすることでPromiseではなく結果の値を直接持たせることが可能です。
この挙動はちょっと変化します。visible中にupdateボタンを押下してみてください。なんと"loading..."が出ません。理由は、Promiseの解決場所がイベントハンドラー内になったからですね。あえてこの方が嬉しい場面もあるかもしれません。
- const dataAtom = atom(requestData(initParams.id));
+ const dataAtom = atom<Promise<string> | string>(requestData(initParams.id));
- const updateAtom = atom(null, (get, set) => {
+ const updateAtom = atom(null, async (get, set) => {
set(paramsAtom, (c) => ({ ...c, id: c.id + 1 }));
- set(dataAtom, requestData(get(paramsAtom).id));
+ set(dataAtom, await requestData(get(paramsAtom).id));
});
おわりに
今回、Activityのhidden modeでのpre-renderingに関して、Jotaiとの組み合わせによる実践的な可能性を探ってみました。
一度visibleにしたコンポーネントを、再度hiddenにしたとしても同様の効果を得られることがわかったので個人的には満足しています。
ところで、useSWRやTanStack QueryもSuspense対応していたと思うので、初回hidden modeでのpre-renderingは可能だと思いますが、本記事で試したようなパターンを同じように再現できるかは不明です。どなたか試していただければ嬉しいです。
最近は久しぶりにJotaiの話題も増えてきたように思うので、一ファンとして、もっと盛り上がることを期待です👻
React TokyoミートアップやDiscordサーバーでお会いしましょう👋
Discussion