💬

Recoil の atom の初期化タイミングで1度だけ effect を呼びたい場合

2021/05/05に公開

課題

カスタムフックの中で atom を使うと、アプリケーション全体でそのカスタムフックを何度呼ぼうとも同じ state を参照する事ができる。
この state に紐づけて初期化処理の effect を行いたい場合 (例えば keydown で state を更新するなど) に、recoil 自体にはその機能は無い (atom には effects_UNSTABLE があるが、これは set/get のタイミングで処理を呼びたい場合にしか今の所使えない)。
カスタムフックの中で普通に useEffect を使ってしまうと useEffect はコンポーネント単位で実行されてしまうため、アプリケーションの複数箇所でそのカスタムフックを呼んだ場合に同じ effect が複数回呼ばれてしまう。
ライフサイクルとしてはアプリケーションの初期化タイミングでの処理なので、ルートコンポーネントの effect で atom の初期化に紐付けたい処理を呼べばいい気もするが、atom の宣言と実際に初期化が実行される場所が別々に定義されてしまうため見通しが悪い。

解決方法

effect が実行されたかの atom を dangerouslyAllowMutability オプション付きで作り、useEffect で初期化されたらその state を ref のように破壊的に変更する。

useMyHook.ts
const isInitializedState = atom({
  key: "isInitializedState",
  default: { value: false },
  dangerouslyAllowMutability: true
});

const useMyHook = () => {
  // atom を使う処理
  const isInitializedRef = useRecoilValue(isInitializedState);
  useEffect(() => {
    if (isInitializedRef.value) return;
    isInitializedRef.value = true;
    // ...初期化処理
  }, []);
};

以下、上記のコードに至るまでの案と問題点を挙げてゆく。

案1と問題点

カスタムフックの中で useEffect を使って初期化処理を実行する。

useMyHook.ts
const useMyHook = () => {
  // atom を使う処理
  useEffect(() => {
    // ...初期化処理
  }, []);
 };

このカスタムフックがアプリケーション内のコンポーネントで1回しか使用されないなどの場合にはこの書き方で問題ないかもしれない。
複数のコンポーネントがこのカスタムフックを使用すると、コンポーネントごとに useEffect の処理が行われてしまう為、atom の初期化時に1度だけという要件に反してしまう。

案2と問題点

案1に加えて、初期化処理が行われたかのフラグを useRef を使って持っておく。

useMyHook.ts
const useMyHook = () => {
  // atom を使う処理
  const isInitialized = useRef(false);
  useEffect(() => {
    if (isInitialized.current) return;
    isInitialized.current = true;
    // ...初期化処理
  }, []);
 };

初期化処理が行われたかのフラグを持つという発想は良いが、useRef もまたカスタムフックを使用したコンポーネントごとに独立して管理される為に案1と同じ問題を持っている。

案3と問題点

案2の useRef がだめならばアプリケーション全体で共有できる atom をフラグとして使う。

useMyHook.ts
const isInitializedState = atom({
  key: "isInitializedState",
  default: false,
});

const useMyHook = () => {
  // atom を使う処理
  const [isInitialized, setIsInitialized] = useRecoilState(isInitializedState);
  useEffect(() => {
    if (isInitialized) return;
    setIsInitialized(true);
    // ...初期化処理
  }, []);
 };

かなり解決方法のコードと近い。
しかしこのカスタムフックの問題点は同時に複数のコンポーネントで呼ばれた場合にある。
コンポーネント1とコンポーネント2が同時にこのカスタムフックを呼んだ場合に、それらのコンポーネント内でのカスタムフックの呼び出しは同期的 (同tick) で呼ばれることになる。
1つ目のコンポーネントで setIsInitialized(true) を行っても、この変更が反映されるのは 次のtick なので、2つ目のコンポーネントで参照する isInitializedfalse のままになってしまい、初期化処理が2回実行されてしまう。

最終的なコード

useMyHook.ts
const isInitializedState = atom({
  key: "isInitializedState",
  default: { value: false },
  dangerouslyAllowMutability: true
});

const useMyHook = () => {
  // atom を使う処理
  const isInitializedRef = useRecoilValue(isInitializedState);
  useEffect(() => {
    if (isInitializedRef.value) return;
    isInitializedRef.value = true;
    // ...初期化処理
  }, []);
};

案3の問題点は atom の参照と更新が同tick だと古い情報を参照してしまう事だった。
これを解決するには dangerouslyAllowMutability オプションを使って同期的に値を変更するしか無い。
このようにすることで React の useRef のようにコンポーネントライフサイクルとは切り離して更新ができるようになる。
useRef を使った場合に ref.current を書き換えるように、atom に管理してもらう値はプリミティブな値を直接ではなく一度オブジェクトに包んで、更新はオブジェクトのフィールドの値を再代入する形で行う必要がある。

dangerously な名前の機能を使うのは慎重であるべきで、今回の場合であればカスタムフック内に閉じた atom として管理しているので良しとしている。

Discussion