👻

Jotaiをv2にMigrationした記録

2023/02/02に公開

株式会社パルケの手を動かすCTO、みつるです。

2023年2月1日に、愛用のライブラリのJotaiのメジャーバージョンアップ(v2)がリリースされました。

https://github.com/pmndrs/jotai/releases/tag/v2.0.0

パルケでは、ビジネスチャットとオンラインミーティングのアプリを提供しています。

無料でずっと話せるミーティングアプリ パルケミート
とにかく簡単につながる無料ビジネスチャット パルケトーク

これらの自社開発アプリの中で、状態管理ライブラリとしてJotaiを積極的に活用しています。
Jotaiが好きすぎて、先日は以下のような記事を投稿していました。

https://zenn.dev/mitsuruokura/articles/ffcb8c703b1bdc

今回、早速自社プロダクトをv2に移行しましたので、その記録を残しておきたいと思います。

Jotai v2の変更点

これまではJotaiは原則Reactのコンポーネントの中でしか利用できませんでした。

実験的なunstable_createStoreで利用可能でしたが、今回のメジャーバージョンアップでunstable_が取れて安心して利用できるようになりました。

外部ストアとして定義できるようになったことで、React外のバニラJSでも利用できるようになった事が大きいと思います。

次のサンプルコードの通り、storeを作成しておけば、どこからでも値をセットしたり現在値を取り出せるようになります。

import { createStore } from 'jotai' // or from 'jotai/vanilla'

const store = createStore()
store.set(fooAtom, 'foo')

console.log(store.get(fooAtom)) // prints "foo"

const unsub = store.sub(fooAtom, () => {
  console.log('fooAtom value in store is changed')
})

Reactで実装していると必要になるシーンはほとんどありませんが、稀にcallbackの中で現在の最新の値だけ欲しいなーといった事があります。

テストコードの中で初期値設定したり値を書き換えたり、Storybookの表示バリエーションの確認などで使うと便利だと思います。

Migrationの記録

公式に移行手順が詳しく記載されていましたので、その記載に沿って作業を進めました。

https://jotai.org/docs/guides/migrating-to-v2-api

パルケのプロダクトでは4点のソースコードの変更が必要でした。

  1. unstable_createStorecreateStoreに置き換え
  2. ProviderのinitialValueをstoreへのsetに置き換え
  3. 更新Atomの型定義の記載の変更
  4. atomWithStorageの初期読み取りの変更

unstable_createStorecreateStoreに置き換え

変更前

// v2ではunstable_createStoreが存在しないのでエラー
import { Provider, unstable_createStore } from 'jotai';
export const jotaiStore = unstable_createStore();

export const JotaiProvider = ({ children }: { children: React.ReactNode }) => (
  <Provider unstable_createStore={() => jotaiStore}>{children}</Provider>
);

変更後

// unstable_を削除
import { Provider, createStore } from 'jotai';
export const jotaiStore = createStore();

export const JotaiProvider = ({ children }: { children: React.ReactNode }) => (
  <Provider store={jotaiStore}>{children}</Provider>
);

unstable_createStoreはしっかりTypescriptの型エラーとなっていました。

ProviderのinitialValueをstoreへのsetに置き換え

StorybookやVitestのコードの中でProviderのinitialValueを多用していました。
v2からはinitialValueを使わずに、createStoreしたstoreへのsetを使うように、という事でした。

変更前

  <Provider initialValues={[[isMobileAtom, true]]}>
    <div className="bg-warm-gray-100 h-[667px] w-[375px]">
      <DisplayGroup label="表示名">parque_Takashi</DisplayGroup>
    </div>
  </Provider>

変更後

const store = createStore();
store.set(isMobileAtom, true);

export const MobileDefault = () => (
  <Provider store={store}>
    <div className="bg-warm-gray-100 h-[667px] w-[375px]">
      <DisplayGroup label="表示名">parque_Takashi</DisplayGroup>
    </div>
  </Provider>
);

個々の修正は難しくないのですが、StorybookやテストのコードでinitialValueを利用していたファイルが多く修正するのが大変でした。

更新Atomの型定義の記載の変更

更新Atomで、更新時に渡す引数が、配列で指定するようになりました。
これは更新Atomで複数のパラメータをカンマ区切りで渡せるようにするためだと思います。

変更前

// atomの2つ目のstringが配列ではないのでエラー。また3つ目の定義も必要
export const successEmailInputAtom = atom<null, string>(null, (_, set, email) => {
  set(emailToChangeAtom, email);
  set(authStateAtom, 'verifyEmail');
});

変更後

export const successEmailInputAtom = atom<null, [string], unknown>(null, (_, set, email) => {
  set(emailToChangeAtom, email);
  set(authStateAtom, 'verifyEmail');
});

atomの二つ目の型を配列に(今回の例ではstringから[string])に、三つ目の型をunknownと指定する必要がありました。

atomWithStorageの初期読み取りの変更

今回、ここが一番苦戦しました。

公式より抜粋

https://jotai.org/docs/utils/atom-with-storage

v1では、atomWithStorageは初回の読み込みでLocal StorageまたはAsync Storage(React Nativeの場合)から呼び出しが終わるまではsuspendしていました。

v2からはSSRでの互換性を高めるように、Local StorageやAsync Storageに値が存在していてもinitialValueで初回レンダリングされるようになっていました。

公式では、クライアントだけでHydrationする方法を推奨されていました。

https://www.joshwcomeau.com/react/the-perils-of-rehydration/#abstractions

ただ、この方法はWebのLocal Storageではうまくいったものの、React NativeのAsync Storageではうまくいきませんでした。

結果的に、loadable APIを利用して、loadingの場合はレンダリングを止める事で解消できました。

import { useAtomValue } from 'jotai';
import { loadable } from 'jotai/utils';
import React from 'react';
import { Freeze } from 'react-freeze';
import { authStateAtom, tokenAtom, userAtom } from '~core/auth/atoms/auth';
import { navigationStateAtom } from '~core/common/atoms/navigation';
import { Fallback } from '../Fallback';

/**
 * [概要]
 * Atomのプリロードを行う。
 * AsyncStorageまたはLocalForageからデータを取得できるまでFreezeする
 */
export const PreLoader = (props: { children: React.ReactNode }) => {
  const loadingStates = [
    useAtomValue(loadable(userAtom)),
    useAtomValue(loadable(tokenAtom)),
    useAtomValue(loadable(authStateAtom)),
    useAtomValue(loadable(navigationStateAtom)),
  ];

  // まだlocalstorage (AsyncStorage) から復元できていない場合はloadingとなっている。
  const loading = loadingStates.findIndex((each) => each.state === 'loading') > -1;
  return (
    <Freeze freeze={loading} placeholder={<Fallback />}>
      {props.children}
    </Freeze>
  );
};

上記のように、Local Storage (またはAsync Storage)から読み込みが終わるまではReact Feezeを使ってchildrenがレンダリングされないようにしています。

loadable APIを利用することで、それぞれのatomのstate (loading | hasData | hasError)が取れるようになります。一つでもloadingの場合は読み込み完了が前提のコンポーネントをレンダリングさせないようにしました。

これで必要なMigrationが完了しました。

最後に

株式会社パルケでは、無料で誰でも簡単に使えるミーティングアプリ、チャットアプリを運用しています。
興味がありましたらぜひ利用してみてください。

無料でずっと話せるミーティングアプリ パルケミート
とにかく簡単につながる無料ビジネスチャット パルケトーク

また、React・React Nativeを使ったアプリ開発のご依頼も承っております。
お仕事のご相談は気軽にTwitterのDMからご相談ください。

Discussion