👻

Recoil が React 19 で動かないらしいので Jotai に移行した

2024/12/17に公開

背景

先日 SNS で 「React 19 で Recoil が動かないので剥がしている」 という情報を目にしました。

https://x.com/kiririLee/status/1866843717086437603

私が関わっているプロジェクトで Recoil を使用しているものがあるのですが、React 19 で動かないというのはこの資料を見て初めて知ったので驚きました。

実際に React 19 にアップグレードしてみたところ、確かに画面が真っ白になってしまい、コンソールを見ると Uncaught TypeError: Cannot destructure property 'ReactCurrentDispatcher' of 'import_react.default.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED' as it is undefined. というエラーが表示されていました。

React 19 へのアップグレードも今すぐ必須というわけではなく、待っていれば Recoil が対応してくれる可能性もありますが、Recoil は開発が止まっており脱却したいという話がかねてから社内でも挙がっていたため、これを期に脱 Recoil を検討しました。

そこで Jotai への移行について調べたところ、「Jotai と Recoil は似ていて移行が簡単」というような情報は多く見つかったものの既に Recoil が導入されているところから Jotai への具体的な移行手順は見当たらなかったため、メモとして残すことにしました。

脱 Recoil の方法検討

参照の資料では Recoil で持っていた状態を React 標準の ContextuseState に移行したり URL で持つなどいくつかのパターンが紹介されていますが、それぞれ個別に対応していく必要があり骨が折れる作業です。

Jotai は実務で使用したことこそありませんでしたが、Recoil と非常にインタフェースが似ているということを以前からドキュメントを読んで知っていたため、既存のコードを極力変更せずに脱 Recoil する方法として Jotai への移行が第一候補となりました。

なお jotai-recoil-adapter という Jotai の上に Recoil と同じインタフェースの API を提供するライブラリもあるようですが、これを使っても本来の Jotai の API への書き換えを将来に先送りするだけですし、そもそもこちらも現時点で React 19 で動かないらしい[1]ので一気に書き換えることにしました。

移行手順

useRecoilState, useRecoilValue, useSetRecoilState の書き換え

状態を参照するための基本のフックは以下の通り対応します。

Recoil Jotai
useRecoilState useAtom
useRecoilValue useAtomValue
useSetRecoilState useSetAtom

フックの名前を変更し、インポート元も recoil から jotai に変更します。

- import { useRecoilState, useRecoilValue, useSetRecoilState } from "recoil";
+ import { useAtom, useAtomValue, useSetAtom } from "jotai";

この 3 つはインタフェースも同じですしほとんどの場合プロジェクト内の一括置換で対応可能かと思います。

atomkey を削除する

atom を定義する際 Recoil では atom({ key, default }) ですが Jotai では string key は使用せず atom(initialValue) という形になります。

これについては Jotai 作者の daishi さんも Zenn で記事を書かれています。

https://zenn.dev/jotaifriends/articles/0c1f4c3a6ed7e5

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

- const counter = atom<number>({ key: "counter", default: 0 });
+ const counter = atom<number>(0);

atom が多い場合はちょっと書き換えが大変ですが、シンプルになりますねすね!

TypeScript では上記のように型引数を指定している場合は atom の定義で型エラーになり修正箇所がわかりやすいですが、そうでなくともほとんどの場合は参照しているコンポーネント側で型エラーが発生するはずです。

selectoratom で書き直す

Recoil では他の atom から派生した getter や setter を持つ状態を定義するために selector を使用します。

Jotai では selector が見当たらないのでどうやって定義するんだろうと思いましたが、なんと selector 相当のものも atom を使って定義します。[2]

- const doubledCounter = selector({
-   key: "doubledCounter",
-   get: ({ get }) => get(counter) * 2,
- });
+ const doubledCounter = atom((get) => get(counter) * 2);

ここでは割愛しますが Jotai のドキュメントに

  • Read-only atom (getter のみ持つ)
  • Write-only atom (setter のみ持つ)
  • Read-Write atom (getter / setter 両方を持つ)

のそれぞれの定義方法が載っています。

https://jotai.org/docs/core/atom

なお TypeScript では(特に setter を持つときの)型引数が少し複雑になるため、型引数を指定せず推論を使用することをドキュメントでは推奨しています。[3]

<RecoilRoot> を削除、または <Provider> に置き換える

Recoil を使うコンポーネントは必ず <RecoilRoot> を親に持つ必要があります。

Jotai には似たようなものとして <Provider> がありますが必須ではありません。

任意で <Provider> を置くとその配下で状態が分離され、もし <Provider> が再マウントされるとその時に状態がリセットされるという特性を持ちます。

通常のアプリケーションコードでは <Provider> は無くてもいい(あってもいい)ですが、テストコードではテストケース毎に確実に状態をリセットするために囲っておくといいかなと思いました。

https://jotai.org/docs/core/provider

その他の API(useResetRecoilState など)を書き換える

ここまでで Recoil の大半のユースケースは書き換わったと思いますが、プロジェクトによってはその他の使っている API を書き換える必要があります。

今回私のプロジェクトでは useResetRecoilState という API を使っていたのですが Jotai でも useResetAtom という API が jotai/utils から提供されており書き換え可能でした。

- import { atom, useResetRecoilState } from "recoil";
+ import { atomWithReset, useResetAtom } from "jotai/utils";

useResetAtom には通常の atom ではなく atomWithReset で定義された atom を渡します。
TypeScript を使用していれば型エラーになるので簡単に気づくことができます。

ちなみにこの atomWithResetソースコードを見ると通常の atomRESET という Symbol を受け取ったら initialValue に戻すようにラップしただけで、 useResetAtomRESET を渡しているだけというとてもシンプルな作りになっています。

動作確認、テストコード修正

以上の書き換えによって型エラーもなくなりおおむね動いていたのですが、Recoil と Jotai で新しい状態がコンポーネントに反映されるタイミングが微妙に異なる(?)ようで、以下の現象が発生していました。

  • 状態の更新を確認する React Testing Library のテストコードの一部が失敗するようになった
  • 状態の更新直後に React Router の navigate() を呼び出している箇所で遷移後に更新前の状態が読み込まれてしまうようになった

詳しい原因までは追えていませんが、そもそもどちらもレースコンディションとなるような実装だったため、前者はテストコードに waitFor をつけたり後者は状態が更新されたのを確認してから画面遷移するなどの修正で対応しました。

まとめ

今回のプロジェクトでは私が入る前に Recoil が導入されており私自身は Recoil についてそこまで詳しくなく、Jotai も知ってはいたものの初めて本格的に使うというようなレベル感でしたが、API が似た作りになっていたたおかげで Jotai のドキュメントを見ながら大半の書き換えは1時間以内で終わり、失敗するようになったテストの修正で数時間かかったという感じでした。

今後 Recoil が React 19 に対応する可能性もありますが、そうであっても開発が再び活発化しない限りは継続性の懸念が残るため、今回の件をきっかけに脱 Recoil に向けて動くプロジェクトは多くなりそうです。

もちろん長期的に見て Jotai が何らかの理由で同じように動かなくなってしまう可能性はゼロではないので完全に安心とは言い切れないのですが、現時点でとれる対応として Jotai への移行はとても良い選択肢の一つですし、このメモが少しでもお役に立てればいいなと思いました。

脚注
  1. https://github.com/clockelliptic/jotai-recoil-adapter/issues/32 ↩︎

  2. Recoil だと useRecoilStateatomselector も受け取りますが Jotai ではどちらも atom だから useAtom なんですかね、と書いていて思いました ↩︎

  3. https://jotai.org/docs/guides/typescript#derived-atoms-can-mostly-have-their-types-inferred ↩︎

GENDA

Discussion