Reactのuseを理解しようとして自作してみたら不完全なおもちゃができた件

Reactの`use`を理解しようとして自作してみました。それなりに動作するものはできつつも、複数箇所で呼び出せないオモチャのようなものができあがったのでこのブログに供養します。
2024.05.21

こんちは、 リテールアプリ共創部のmorimorikochanです。

5/31(金)にクラスメソッド大阪オフィスにて クラスメソッドのReact事情大公開スペシャル#3 を計画しています。
今回は過去最多の人数&外部登壇者もいらっしゃるので面白くなりそうです、もし興味ある人は以下のページを覗いてみてください!

LT枠が全て埋まらない見込みなので実は私もLTの準備をしていまして、Reactのuseについて話そうかなーと思っています(上記connpassに記載した登壇タイトルでは話せなくなりました🫠)。
そこで、仕様理解のためにダメ元でuseを再実装してみたのですが、それなりに動いていたのでブログで紹介します。

公式ドキュメントを読んでもあまり動きが理解できない方にとってはもしかすると理解のサポートになるかもしれません...😌

useが満たすべき仕様

useが何でどんなことができるか、については以下の公式ドキュメントなどをご参照ください

自分の勝手な解釈では、useが満たすべき仕様は以下の通りだと考えてます。

  1. インタフェースとして、Promise<T>を引数として受け取り、Tを返す。
  2. Promise<T>を内部で状態として管理する。
  3. 何度呼び出されても、初回以降の引数Promise<T>は管理せず無視する
  4. 初回の呼び出し時は必ずPromise<T>をthrowする
  5. Promise<T>fulfilledになった場合、再レンダリングが実行される
  6. 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本体にもあるわけではないと感じています。

このように自作することは「車輪の再発明」であって、意味が薄い! というふうに捉えることもできますが車輪の再発明でしか学べないようなこともあると僕は思ってます。ぜひ皆さんも一度自作してみてください。

検証用リポジトリ

https://github.com/diggymo/react-use-survey