Reactのuseを理解しようとして自作してみたら不完全なおもちゃができた件
こんちは、 リテールアプリ共創部のmorimorikochanです。
5/31(金)にクラスメソッド大阪オフィスにて クラスメソッドのReact事情大公開スペシャル#3 を計画しています。
今回は過去最多の人数&外部登壇者もいらっしゃるので面白くなりそうです、もし興味ある人は以下のページを覗いてみてください!
LT枠が全て埋まらない見込みなので実は私もLTの準備をしていまして、Reactのuse
について話そうかなーと思っています(上記connpassに記載した登壇タイトルでは話せなくなりました🫠)。
そこで、仕様理解のためにダメ元でuse
を再実装してみたのですが、それなりに動いていたのでブログで紹介します。
公式ドキュメントを読んでもあまり動きが理解できない方にとってはもしかすると理解のサポートになるかもしれません...😌
use
が満たすべき仕様
use
が何でどんなことができるか、については以下の公式ドキュメントなどをご参照ください
自分の勝手な解釈では、use
が満たすべき仕様は以下の通りだと考えてます。
- インタフェースとして、
Promise<T>
を引数として受け取り、T
を返す。 Promise<T>
を内部で状態として管理する。- 何度呼び出されても、初回以降の引数
Promise<T>
は管理せず無視する - 初回の呼び出し時は必ず
Promise<T>
をthrowする Promise<T>
がfulfilled
になった場合、再レンダリングが実行されるPromise<T>
がrejected
になった場合、エラー(Promise<T>
ではない)がthrowされる
これらは公式ドキュメントで示されているわけではなく、私自身が勝手に考えて定義した仕様です。なので間違っている可能性があります。
いくつかここから妥協しながらuse
を自作し、それっぽく?動作するものができました。
できたもの・妥協点
let cache: Promise<unknown> | null = null; let resolvedValue: | { status: "fullfiled"; value: unknown; } | { status: "rejected"; error: unknown; } | null = null; const useCustom = <T,>(promise: Promise<T>): T => { const [, setKey] = useState(0); const onForceUpdate = () => { setKey((key) => key + 1); }; const isCached = cache !== null; console.log({ isResolved: resolvedValue !== null, isCached }); if (isCached) { if (resolvedValue === null) throw cache; if (resolvedValue.status === "fullfiled") { return resolvedValue.value as T; } if (resolvedValue !== null && resolvedValue.status === "rejected") { throw resolvedValue.error; } console.warn("不明なステータス", resolvedValue); } promise .then((v) => { resolvedValue = { status: "fullfiled", value: v }; onForceUpdate(); }) .catch((error) => { resolvedValue = { status: "rejected", error }; onForceUpdate(); }); cache = promise; throw promise; };
使用例としては以下のような感じです。
実行するとはじめにRENDERING
がログ出力されて画面ではローディング表示され、数秒後にRENDERING
およびDATA FETCHED!
がログ出力され画面に自分の名前が表示されます。
この挙動はuse
と同じなのでuseCustom
をReact本体のuse
に書き換えても同じように動作します。
const App = () => { console.log("RENDERING!"); const myName = useCustom(fetchMyName()); console.log("DATA FETCHED!"); return <p>{myName}</p>; }; export const AppContainer = () => { return ( <ErrorBoundary fallback={<p>エラーが発生しました</p>}> <Suspense fallback={<div>loading...</div>}> <App /> </Suspense> </ErrorBoundary> ); };
この環境ではうまく動作したものの、妥協点がいくつかあります。
上述の6つの仕様のうち、5の再レンダリングについては、useState
の値を適当に更新することで実現しています。
これは、React本体内部のコードであれば適切なAPIが提供されていて強制再レンダリングができるはずなのでしょうが、今回は利用できないためこのようにしています。
React本体のuse
に比べて無駄な計算が走っていると推測できます。
const [, setKey] = useState(0); const onChange = () => { setKey((key) => key + 1); };
また、2については、今回作成したカスタムフックの内部で変数を管理しているのではなく、グローバル変数で状態の管理をしています。
let cache: Promise<unknown> | null = null; let resolvedValue: | { status: "fullfiled"; value: unknown; } | { status: "rejected"; error: unknown; } | null = null;
この制限があるので2箇所以上でこの自作use
を利用することはできません。(状態管理がバッティングするので当たり前ですが...😇)
ちなみに、状態をグローバル変数で管理しなくても自作use
の中でuseState
で管理すれば、自作use
の中で状態管理を閉じることができ、上記の問題が解決できる!と閃いて試したのですが、あえなく無限ループになってしまいました。。
どうやらuseState
の更新ができずずっとnull
のままになるみたいです。ここら辺が自作する時の限界なのかなーと感じました。
逆にいうとこれが完全に自作できるなら、React本体より早く誰かがライブラリを作ってそうなはずですしね。。
const useCustom2 = <T,>(promise: Promise<T>): T => { const [, setKey] = useState(0); const [finalValue, setFinalValue] = useState< | { status: "fullfiled"; value: T } | { status: "rejected"; error: unknown } | null >(null); const [savedPromise, setSavedPromise] = useState<Promise<T> | null>(null); const onForceUpdate = () => { setKey((key) => key + 1); }; const isResolved = finalValue !== null; const isCached = savedPromise !== null; console.log({ finalValue, savedPromise }); if (isCached) { if (!isResolved) throw savedPromise; if (finalValue !== null && finalValue.status === "fullfiled") { return finalValue.value as T; } if (finalValue !== null && finalValue.status === "rejected") { throw finalValue.error; } console.warn("不明なステータス"); } promise .then((v) => { setFinalValue({ status: "fullfiled", value: v }); onForceUpdate(); }) .catch((error) => { setFinalValue({ status: "rejected", error }); onForceUpdate(); }); setSavedPromise(promise); throw promise; };
学んだこと
use
に渡すPromise<T>
にはキャッシュの仕組みが必須であることuse
側でPromiseの状態管理がされるものの、コンポーネントで呼び出されている以上は何度も呼び出される前提で考えなければなりません。- 例えば
use(axios.get("/me"))
なんてしようものなら、想定している何倍もの回数でAPI呼び出しが発生してしまいます。
- (当たり前なのですが)async/awaitを利用せずにPromiseの中の値を取得することは何がどうなってもできないこと
- 今回は何度も関数を呼び出すことによって擬似的にしか実現できませんでした。
- 一度の関数呼び出しでPromiseの中の値を取得できるマジックはありませんでしたし、React本体にもあるわけではないと感じています。
このように自作することは「車輪の再発明」であって、意味が薄い! というふうに捉えることもできますが車輪の再発明でしか学べないようなこともあると僕は思ってます。ぜひ皆さんも一度自作してみてください。