RevComm Tech Blog

コミュニケーションを再発明し 人が人を想う社会を創る

MiiTel PhoneでRecoilからJotaiへの移行を行いました

はじめに

今年のはじめにRecoilのGitHubリポジトリがアーカイブされ、話題になりました。

github.com

弊社で提供している MiiTel Phone においても、フロントエンドの状態管理のためにRecoilを採用していました。

Recoilのメンテナンス停止に伴い、今後、バグや脆弱性などに関するリスクが増加してしまう可能性があることや、React v19や周辺ライブラリのアップデートなどに当たって問題となる可能性もあります。そのため、RecoilからJotaiへの移行を実施することにしました。

Recoilからの移行先について

Recoilから別のライブラリへの移行については、すでに他の企業でも事例がありそうです。

speakerdeck.com

弊社においては以下の理由から、Recoilからの移行先としてはJotaiが最も有力な選択肢と判断し、移行先として選択しました。

  1. JotaiはAPIや思想がRecoilに近く、移行コストを抑えやすい
  2. RevCommにおける他のサービスにおいてすでにJotaiの利用実績があり、十分に安定して動作することが期待できる
  3. JotaiのリポジトリやJotaiの作者であるdai-shiさんのブログなどにおいてアウトプットが活発に行われており、今後、採用事例などがより増えることが期待できる
  4. Jotaiはドキュメントが充実しており、またRecoilと比較するとライブラリのサイズも大幅に小さいです。調べたいことや気になることなどがあった際に、ドキュメントやソースコードなどから調査が行いやすいと考えられます。

Jotai について

Jotaiが開発された経緯や概要については、作者であるdai-shiさんによる解説記事が公開されています。これらの記事を参照するのがおすすめです。

zenn.dev zenn.dev zenn.dev

移行の方針

まず、RecoilからJotaiへの移行にあたって、ビッグバンリリースは避けたいと考えていました。できる限り、すでに存在する機能への影響やバグの発生を最小限に抑えつつ、段階的に移行が行えると理想的です。そこで、以下のような方針で移行を進めていくことにしました。

  1. MiiTel Phoneにおいて使用されているRecoilのAPIを一通り洗い出して、それぞれのAPIにおけるJotaiへの移行方法を調査する
  2. 依存関係としてjotaiパッケージを追加する
  3. MiiTel Phoneにおける特定の機能において、RecoilからJotaiへの移行を実施する
  4. 検証環境で様子を見る
  5. 問題のない機能から順次、リリースを実施する
  6. 3〜5のステップを繰り返す
  7. 一通りの機能で移行が完了したら、依存関係からrecoilパッケージを削除する

1. MiiTel Phoneにおいて使用されているRecoilのAPIを一通り洗い出して、それぞれのAPIにおけるJotaiへの移行方法を調査する

まずは、Jotaiへの移行が現実的に可能であることや具体的な移行方針を決めやすくするために、MiiTel PhoneにおけるRecoilの各APIの使用方法を洗い出して、それらのAPIのJotaiへの移行方法を調査しました。幸いなことに、Jotaiが提供するjotai/utilsモジュール (詳細は後述します) において、Recoilが提供する機能はほとんどカバーされていることがわかりました。調査を通して懸念や移行方法などは概ね把握できたため、実際に移行を進めていくことにしました。

2. 依存関係としてjotaiパッケージを追加する

RecoilからJotaiへの移行に当たり、重要度や影響度合いの低い機能から優先して段階的に移行が行えると理想的です。幸いなことに、JotaiはRecoilと比較してフットプリントがかなり小さいです。そこで、移行期間中はjotaiパッケージとrecoilパッケージがプロジェクトに共存した状態で移行を進めていくことにしました。recoilパッケージについては、一通り移行が済んでから削除します。

3. MiiTel Phoneにおける特定の機能において、RecoilからJotaiへの移行を実施する

重要度や影響が低めの機能から優先して、順次、RecoilからJotaiへの移行を実施します。MiiTel PhoneではQaseというサービスを使って手動のテストケースを管理しています。そこで、Qaseによってテストがしやすい単位ごとにプルリクエストを分割して、各機能におけるRecoilを使用したコードをJotaiへ段階的に移行していきました。

また、もしAtom EffectsやselectorなどのRecoilにおける高度な機能を使用している箇所については、移行に先立ってユニットテストを用意しておくとより安全に移行が行えます。React Testing LibraryrenderHook()を使用して該当のatom/selectorもしくはそれらを利用するカスタムフックに対してテストを記述しておくと、Jotaiへ移行する際のテストの書き換えをできる限り抑えられて良いと思います (下記の例だと、RecoilRootuseRecoilState()をそれぞれJotaiのProvideruseAtom()へ置き換えるだけで移行できるはずです)

import { renderHook } from '@testing-library/react';
import { RecoilRoot, useRecoilState } from 'recoil';
import { act } from 'react';

describe('preferencesState', () => {
  afterEach(() => localStorage.clear());

  it('persists state to localStorage', () => {
    const { result } = renderHook(
      () => {
        const [preferences, setPreferences] = useRecoilState(preferencesState);
        return { preferences, setPreferences };
      },
      {
        wrapper: RecoilRoot,
      },
    );
    expect(localStorage.getItem('preferences')).toBe(null);
    
    act(() => result.current.setPreferences({ theme: 'dark' }));
    
    expect(result.current.preferences).toEqual({ theme: 'dark' });
    expect(localStorage.getItem('preferences')).toBe(JSON.stringify({ theme: 'dark' }));
  });
});

4. 検証環境で様子を見る

今回のRecoilからJotaiへの移行に当たって、新機能の開発や要望への対応などはストップせずに、それらのタスクと並行しながら進めました。RecoilからJotaiへの移行を実施した機能については、すぐにはリリースをせずに一週間ほど検証環境にデプロイをして様子を見ることにしました。

5. 問題のない機能から順次、リリースを実施する

検証環境で様子を見て特に問題がなさそうであれば、他の機能や修正などと合わせて少しずつJotaiへ移行したコードをリリースしていきました。

6. 3〜5のステップを繰り返す

一通り移行が完了するまで、関連した機能ごとにRecoilからJotaiへの移行を行い、少しずつ段階的にリリースを進めていきます。重要度や影響度の高い機能については移行を後回しにして、最後にまとめて移行をすることにしました。

7. 一通り移行が完了したら、依存関係からrecoilパッケージを削除する

すべてのRecoilのコードをJotaiへ移行し終えたら、ようやくrecoilパッケージを削除できます。今回の移行に当たって段階的に移行を進めていたことや、RecoilとJotaiは全体的に思想やAPIがよく似ていて移行が行いやすかったこともあり、特に障害が発生することもなく無事に移行をすることができました。

Recoil と Jotai の各APIの対応と移行方法について

Recoil の各APIごとに、Jotaiへの移行方法について紹介いたします。

jotai/utilsモジュールについて

Recoilが提供する高度なAPIの多くはjotai/utilsモジュールによってカバーされています。この記事でもjotai/utilsモジュールから提供されているAPIをいくつか紹介しますが、紹介していない機能もまだまだあります。jotai/utilsモジュールはatomの活用方法の観点からもとても参考になるため、一度、内容を調べてみるのも良いかもしれません。

atom()

atomの移行は単純で、基本的にはkeyを削除して、デフォルト値をatom()の引数に指定するよう書き換えることで移行できます。

- import { atom } from 'recoil';
+ import { atom } from 'jotai';

- export const isLoadingState = atom<boolean>({
-   key: 'users/isLoading',
-   default: false,
- });
+ export const isLoadingState = atom(false);

ただし、useResetRecoilState()を使用しているatomについてはこの方法では移行できず、後述するatomWithReset()を使用するとよいです。

useRecoilState()

RecoilのuseRecoilState()はJotaiのuseAtom()へそのまま置き換えることができます。

+ import { useAtom } from 'jotai';
- import { useRecoilState } from 'recoil';
  ...
-   const [counter, setCounter] = useRecoilState(counterState);
+   const [counter, setCounter] = useAtom(counterState);

useSetRecoilState()

RecoilのuseSetRecoilState()はJotaiのuseSetAtom()へそのまま置き換えることができます。

+ import { useSetAtom } from 'jotai';
- import { useSetRecoilState } from 'recoil';
  ...
-   const setIsLoadinge = useSetRecoilState(isLoadingState);
+   const setIsLoading = useSetAtom(isLoadingState);

useRecoilValue()

RecoilのuseRecoilValue()はJotaiのuseAtomValue()へそのまま置き換えることができます。

+ import { useAtomValue } from 'jotai';
- import { useRecoilValue } from 'recoil';
  ...
-   const isLoading = useRecoilValue(isLoadingtate);
+   const isLoading = useAtomValue(isLoadingState);

useResetRecoilState()

useResetRecoilState()を使用したatomをJotaiへ移行するには、jotai/utilsモジュールによって提供されるatomWithReset()を使う必要があります。

- import { atom } from 'recoil';
+ import { atomWithReset } from 'jotai/utils';

- export const isLoadingState = atom<boolean>({
-   key: 'users/isLoading',
-   default: false,
- });
+ export const isLoadingState = atomWithReset(false);

atomWithReset()によって定義されたatomは、jotai/utilsuseResetAtom()によってデフォルト値へのリセットが可能です。

+ import { useResetAtom } from 'jotai/utils';
- import { useResetRecoilState } from 'recoil';
  ...
-   const resetIsLoading = useResetRecoilState(isLoadingState);
+   const resetIsLoading = useResetAtom(isLoadingState);

selector()

Recoilのselectorについては、Jotaiにおいてはderived atomによって同様のことが実現できます。例えば以下のようなselectorがあったとします:

import { atom, selector } from 'recoil';

const countState = atom({
  key: 'count',
  default: 0,
});
const isEvenState = selector({
  key: 'isEven',
  get: ({ get }) => get(countState) % 2 === 0,
});
const doubledCountState = selector({
  key: 'doubledCount',
  get: ({ get }) => get(countState) * 2,
});

この場合、Jotaiでは以下のようにして同じことが実現できます:

import { atom } from 'jotai';

const countState = atom<number>(0);
const isEvenState = atom<boolean>(
  (get) => get(countState) % 2 === 0,
);
const doubledCountState = atom<number>(
  (get) => get(countState) * 2,
);

非同期selector

Recoilの非同期selectorについては、非同期Atomを作成することで同様のことが実現できます:

// Recoilの非同期selector
import { selector } from 'recoil';

export const myProfileState = selector<MyProfile>({
  key: 'myProfile',
  get: async () => {
    const profile = await client.getMyProfile();
    return profile;
  },
});

以下のようにatom()Promiseを返却する関数を渡すことで、同様のことが実現できます:

// Jotaiの非同期atom
import { atom } from 'jotai';

export const myProfileState = atom<Promise<MyProfile>>(
  async () => {
    const profile = await client.getMyProfile();
    return profile;
  },
);

useRecoilValueLoadable()

RecoilのuseRecoilValueLoadable()は非同期selectorに関する状態を問い合わせるためのAPIです:

const loadable = useRecoilValueLoadable<MyProfile>(myProfileState);
switch (loadable.state) {
  case 'loading':
    return <Loading />;
  case 'hasError':
    return <Error error={loadable.contents} />
  case 'hasValue':
    return <Profile profile={loadable.contents} />;
}

Jotaiにおいて同様のことを実現したい場合、まずjotai/utilsで提供されているloadable()というAPIによって非同期atomをラップします:

import { loadable } from 'jotai/utils';
import { atom } from 'jotai';

export const myProfileState = atom<Promise<MyProfile>>(
  async () => {
    const profile = await client.getMyProfile();
    return profile;
  },
);
export const myProfileLoadableState = loadable(myProfileState);

loadable()によって返却されたatomに対してuseAtomValue()を呼ぶことで、useRecoilValueLoadable()とほぼ同様のことが実現できます:

const loadable = useAtomValue(myProfileLoadableState);
switch (loadable.state) {
  case 'loading':
    return <Loading />;
  case 'hasError':
    return <Error error={loadable.error} />;
  case 'hasData':
    return <Profile profile={loadable.data} />;
}

useRecoilCallback()

RecoilのuseRecoilCallback()によって、状態を柔軟に操作することができます:

  const runTaskIfNeeded = useRecoilCallback(
    ({ snapshot }) =>
      async (taskId: TaskId) => {
        const isTaskInProgress = await snapshot.getPromise(isTaskInProgressState);
        if (isTaskInProgress) return;
        snapshot.set(isTaskInProgressState, true);
        try {
          await runTask(taskId);
        } finally {
          snapshot.set(isTaskInProgressState, false);
        }
      },
    [runTask],
  );

useRecoilCallback()jotai/utilsモジュールから提供されるuseAtomCallback()に置き換えることができます:

import { useAtomCallback } from 'jotai/utils';  
  
// ...
  
  const runTaskIfNeeded = useAtomCallback(
    useCallback(async (get, set, taskId: TaskId) => {
        const isTaskInProgress = get(isTaskInProgressState);
        if (isTaskInProgress) return;
        set(isTaskInProgressState, true);
        try {
          await runTask(taskId);
        } finally {
          set(isTaskInProgressState, true);
        }
      },
    [runTask]),
  );

注意点として、Jotaiの公式ドキュメントにも記載されていますが、useAtomCallback()に渡す関数は、基本的に上記のようにuseCallback()を適用しておく必要があります (https://github.com/pmndrs/jotai/blob/v2.12.2/docs/utilities/callback.mdx)

もし、useRecoilCallback()の引数として渡されるオブジェクト (CallbackInterface)のrefresh関数に依存している場合は、次に紹介する方法へ移行する必要があります。

useRecoilRefresher_UNSTABLE()

RecoilのuseRecoilRefresher_UNSTABLE()は非同期selectorを再評価したい場合に利用できます。

  const refresh = useRecoilRefresher_UNSTABLE(myProfileState);

Jotaiで同様のことが実現したい場合は、まずjotai/utilsモジュールで提供されるatomWithRefresh()を使用してatomを作成します:

import { atomWithRefresh } from 'jotai/utils';

export const myProfileState = atomWithRefresh<Promise<MyProfile>>(
  async () => {
    const profile = await client.getMyProfile();
    return profile;
  },
);

そして、このatomに対してuseSetAtom()を呼ぶことで、useRecoilRefresher_UNSTABLE()と同等のことが実現できます:

  const refresh = useSetAtom(myProfileState);

Atom Effects

JotaiにはAtom Effectsに相当する機能はありません。しかし、atomを2つ用意するなどの工夫をすることで、Atom Effectsと同様のことが実現できます。

例えば、以下のように状態の更新時にAtom Effectsを活用してロギングを行なっているatomがあったとします:

import { atom } from 'recoil';

export const countState = atom<number>({
   key: 'count',
   default: 0,
   effects: [
     ({ onSet }) => {
       onSet((newValue) => {
         logger.info('countState has been updated to %d', newValue);
       });
     },
   ],
 });

この場合、Jotaiにおいては2つのatomを組み合わせることで同様のことが実現できます。このように2つ以上のatomを組み合わせて複雑なことを実現するパターンはjotai/utilsモジュールの内部においても頻繁に利用されています。

import { atom } from 'jotai';

const baseAtom = atom<number>(0);
export const countState = atom<number, [number], void>(
  (get) => get(baseAtom),
  (get, set, newValue: number): void => {
    set(baseAtom, newValue);
    logger.info('countState has been updated to %d', newValue);
  },
);

他にも、Jotaiのjotai/utilsモジュールではatomの状態をlocalStorageへ同期してくれるatomWithStorageなどのAPIも提供されています。このようなAPIを活用することで、RecoilにおいてAtom Effectsを利用していたコードを置き換えることも可能です。

atomFamily()/selectorFamily()

注意: ここではjotai/utilsatomFamily()を使用した例を紹介しますが、後述するようにjotai/utilsatomFamily()はユースケースによってはメモリリークを引き起こす可能性があるため、適切なタイミングでクリーンアップする必要があります。

import { atomFamily } from 'recoil';

export const taskState = atomFamily<Task, string>({
  key: 'task',
  default: (id) => ({
    id,
    state: 'todo'
  }),
});

jotai/utilsモジュールからatomFamily()が提供されており、概ね同じような用途で使用できます:

import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils';

export const taskState = atomFamily((id: string) => {
  return atom<Task>({
    id,
    state: 'todo'
  });
});

また、selectorFamily()についても似たような方法で移行ができます:

import { selectorFamily } from 'recoil';

export const tasksByProjectIdState = selectorFamily<string | undefined, Array<Task>>({
  key: 'tasksByProjectId',
  get: (projectId: string) => async ({ get }) => {
    const filter = get(tasksFilterState);
    const tasks = await fetchTasksByProjectIdAndFilter(projectId, filter);
    return tasks;
  },
});

Jotaiにおいては、jotai/utilsモジュールから提供されるatomFamily()と非同期atomを併用することで、概ね同じことが実現できます:

import { atom } from 'jotai';
import { atomFamily } from 'jotai/utils';

export const tasksByProjectIdState = atomFamily(
  async (projectId: string) => {
    return atom<Array<Task>>(
      (get) => {
        const filter = get(tasksFilterState);
        const tasks = await fetchTasksByProjectIdAndFilter(projectId, filter);
        return tasks;
      },
    );
  },
);

atomFamily()はデフォルトでパラメーターの比較を同値性に基づいて行います。そのため、パラメーターとしてプリミティブ値ではなくオブジェクトを指定したい場合は、atomFamily()の第2引数にオブジェクト同士の深い比較を行う関数を指定する必要があります。

Jotaiの公式ドキュメントではfast-deep-equalを使用した例が掲載されています。

github.com

悩みどころ/ハマったところ

atomFamily()/selectorFamily()の移行について

先ほども紹介しましたが、Jotaiが提供するjotai/utilsモジュールにはatomFamily()というAPIがあります。これは名前が示す通り、RecoilのatomFamily()とよく似た振る舞いをしてくれます。

しかし、一つ注意点があります。Jotaiの公式ドキュメントにおいても記載されていますが、atomFamily()は内部において作成されたatomの一覧をMapを用いて管理しています。このMapで保持されているatomの一覧は、該当のatomunmountされたとしても破棄されることはないため、ユースケースによっては意図せぬメモリリークが発生してしまう可能性があります。

github.com

このメモリリークへの対策としては、以下のいずれかが考えられると思います:

  1. AtomFamily#removeを用いて、不要になったatomを削除する
  2. Recoilからの移行に当たり、atomFamily()の使用をやめる

1. AtomFamily#removeを用いて、不要になったatomを削除する

JotaiのatomFamily()が返却するAtomFamilyオブジェクトはremoveというメソッドを提供しています。atomFamily()の内部ではMapを使ってパラメーターと作成されたatomの紐付けを管理しています。

github.com

AtomFamily#removeメソッドにパラメーターを指定することで、Mapから指定されたパラメーターのエントリーを削除することができます。適切なタイミングでAtomFamily#removeを呼ぶことで、Mapに無制限にエントリーが残り続けてしまう問題を回避できます。AtomFamily#getParamsメソッドと併用することで、例えば、atomFamily()が内部にキャッシュするエントリー数に制限を掛けることなどもできそうです。

また、AtomFamilyオブジェクトにはsetShouldRemoveというメソッドもあります。このメソッドには、atomの作成日時 及び atomFamily()に渡されたパラメーターの2つの値を引数として受け取り、booleanを戻り値として返却する関数を指定します。この関数がtrueを返却した場合、atomFamily()の内部で管理されているMapから該当のパラメーターに対応するエントリーが削除されます。古くなったパラメーターに紐づくatomを削除したいケースにおいて役立ちます。

MiiTel Phoneにおいては、できる限り移行のコストを軽減することや、移行に当たって意図せぬリグレッションなどを防止することを優先して、この方法を採用しました。しかし、Jotaiの使い方としては、この方法よりも次に紹介する方法の方がより理想的なのではないかと思っています。

2. Recoilからの移行に当たり、atomFamilyの使用をやめる

Jotaiにおいて、atom()から返却される値の実体はプレーンなオブジェクトです

github.com

JotaiのStoreはこのプレーンなオブジェクトから状態へのマッピングをWeakMapによって管理しています。

公式ドキュメントでも言及されているように、useMemo()などとの併用は必要ですが、Jotaiのatomはコンポーネントのレンダリングフェーズにおいても作成することが可能です。この性質をうまく活用すると、atomFamily()を使用せずに同様のことをより直感的に実現することも可能そうです。

github.com

github.com

MiiTel Phoneにおいても、徐々にこの方式への移行を検討していきたいです。

RecoilとJotaiにおける更新タイミングの微妙な差異について

RecoilからJotaiへ移行するに当たって、微妙なタイミングのずれからuseEffectが意図したタイミングで発火せずに不整合が起きてしまうバグに遭遇しました。

しっかりとした調査ができているわけではないので自信はないですが、RecoilはuseSyncExternalStoreを使っているようで、それが原因で再レンダリングなどのタイミングが微妙にJotaiとは異なっている可能性があるのではないかと推測しています。

github.com

おわりに

Recoilはとても便利なライブラリであり、MiiTel Phoneでもたくさん活用していました。そのため、メンテナンスが停止されてしまい残念には思いましたが、これほどの規模や需要を持つライブラリをメンテナンスし続けることは実際には非常に大変なことなのではないかと思いました。

今回、移行先として選択したJotaiは、全体的にとてもシンプルで使い勝手の良いライブラリだと思いました。ドキュメントも充実しており学習も行いやすく、とても良いライブラリです。Recoilのメンテナンス停止に伴い、今後さらに人気が増すのではないかと思います。

参考

github.com blog.logrocket.com