🐣

reactの状態管理の勉強&Jotaiを使ってみる

2024/09/17に公開

はじめに

効率的にアプリケーションを作るためには状態管理ライブラリが必要になります。
そこでここ数年で大きく利用者数を伸ばしている、Jotaiを使ってみます。
https://jotai.org/

propsのバケツリレーの問題点

まずはじめに状態管理ライブラリを使いたい理由である、propsのバケツリレーの問題点について説明します。

紹介するデモコードは、あるアプリ内のプロフィール画面から、ユーザーネームを変更する場合を想定しています。
最下層のinputフォームで、usernameを変更すると、それがcurrent usernameに反映されます。

今回は各層のコンポーネントを単純化しているので、ただ下層のコンポーネントを表示するだけの処理です。

usernamesetUsernameは、コンポーネントの階層を通じて下層へと渡されていきます。App → UserProfile → UserSettings → PrivacySettings → UsernameInputという順序で、各コンポーネントがこれらのpropsを受け取り、さらに下層のコンポーネントに渡しています。

(このような状況を、深く潜っていく様子から英語でprops drillingと表現するそうです。)

example-before.tsx
"use client";
import React, { useState } from 'react';

const App = () => {
  const [username, setUsername] = useState('JohnDoe');

  return (
    <div>
      <h1>Deep Props Drilling Example</h1>
      <p>Current username: {username}</p>
      {/* usernameに関係ない処理: 例えば他の全体的なアプリの状態管理など */}
      <UserProfile username={username} setUsername={setUsername} />
    </div>
  );
};

const UserProfile = ({ username, setUsername }) => {
  return (
    <div>
      <h2>User Profile</h2>
      {/* usernameに関係ない処理: ユーザープロフィールの他の情報(年齢、住所など)の表示や編集 */}
      <UserSettings username={username} setUsername={setUsername} />
    </div>
  );
};

const UserSettings = ({ username, setUsername }) => {
  return (
    <div>
      <h3>User Settings</h3>
      {/* usernameに関係ない処理: 他の設定(通知設定、テーマ設定など)の管理 */}
      <PrivacySettings username={username} setUsername={setUsername} />
    </div>
  );
};

const PrivacySettings = ({ username, setUsername }) => {
  return (
    <div>
      <h4>Privacy Settings</h4>
      {/* usernameに関係ない処理: プライバシー設定(可視性、ブロックリストなど)の管理 */}
      <UsernameInput username={username} setUsername={setUsername} />
    </div>
  );
};

const UsernameInput = ({ username, setUsername }) => {
  return (
    <div>
      <label htmlFor="username">Change Username: </label>
      <input
        id="username"
        type="text"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      {/* この部分は本当にusernameを使用している */}
    </div>
  );
};

export default App;

中間のコンポーネント(UserProfileUserSettingsPrivacySettings)が、そのコンポーネント内では実際にはpropsを使用しないにも関わらず、下層に受け渡しするためにpropsを受け取っています。

バケツリレー構造にしてしまうと、本来必要のないpropsが渡されることで、以下のような悪影響が出ます。

問題点 説明
可読性低下 何をするコンポーネントなのかがわかりにくくなる
再利用性低下 上層から渡されるpropsがある場合にしか使えず、他の箇所で再利用できない
保守性低下 上層でstate名を変更するなどして、propsが変更された場合にその影響が下層にまで波及する
不必要な再レンダリング propsが変更された場合に受け渡ししているだけの中間コンポーネントも再レンダリングされるため、パフォーマンスが低下する

以上を踏まえて本当に必要なコンポーネント内だけでデータを参照できるようにする、グローバルな状態管理の方法が必要になります。

状態管理ライブラリ

有名なものでRedux(リダックス)、Zustand(チュースタンド:ドイツ語で「状態」の意味)、Recoil、Jotaiなどの状態管理ライブラリがあります。
一長一短があり、特定の一つの圧勝というよりは、エンジニアの好みやプロジェクトの要件に応じて使い分けられている状況です。

https://zenn.dev/kazukix/articles/react-state-management-libraries
https://risingstars.js.org/2023/ja#section-statemanagement

今回はその中でも、最近勢いがあり、比較的学習コストが低いと言われているJotaiを勉強します。

Jotaiとは

Jotaiは、meta社が開発したRecoilの流れを汲んで開発された、日本製の状態管理ライブラリです。
Recoilに比べて、AtomにユニークなKeyを振る必要がありません。

// Jotai の例
import { atom } from 'jotai'

// Jotai では、ユニークなキーを指定する必要がありません
const counterAtom = atom(0)
const nameAtom = atom('John Doe')

// Recoil の例
import { atom } from 'recoil'

// Recoil では、各 atom にユニークなキーを指定する必要があります
const counterAtom = atom({
  key: 'counterAtom', // ユニークなキーが必要
  default: 0,
})

const nameAtom = atom({
  key: 'nameAtom', // ユニークなキーが必要
  default: 'John Doe',
})

また、Recoil系列の状態管理ライブラリでは、Reduxのような単一の大きな状態オブジェクトではなく、複数の小さな状態(Atom)を個別に定義できます。

// Jotai の例
import { atom, useAtom } from 'jotai'

// 複数の独立した状態(Atom)を定義
const counterAtom = atom(0)
const nameAtom = atom('John Doe')
const isLoggedInAtom = atom(false)

// Redux の例
import { createStore } from 'redux'
import { Provider, useSelector, useDispatch } from 'react-redux'

// 単一の大きな状態オブジェクト
const initialState = {
  counter: 0,
  name: 'John Doe',
  isLoggedIn: false
}

Redux系列とRecoil系列の概念の違いはこの記事が参考になります。
https://blog.uhy.ooo/entry/2021-07-24/react-state-management/

Jotaiを使ったデモコード

先ほどのデモコードをJotaiを使って書き換えました。
個別に解説していきます。

example-jotai.tsx
"use client";
import React from 'react';
import { atom, useAtom } from 'jotai';

// グローバルな状態としてusernameのatomを作成
const usernameAtom = atom('JohnDoe');

const App = () => {
  // 値の読み取りのみなので useAtomValue を使用
  const username = useAtomValue(usernameAtom);

  return (
    <div>
      <h1>Jotai Example</h1>
      <p>Current username: {username}</p>
      {/* usernameに関係ない処理: 例えば他の全体的なアプリの状態管理など */}
      <UserProfile />
    </div>
  );
};

const UserProfile = () => {
  return (
    <div>
      <h2>User Profile</h2>
      {/* usernameに関係ない処理: ユーザープロフィールの他の情報(年齢、住所など)の表示や編集 */}
      <UserSettings />
    </div>
  );
};

const UserSettings = () => {
  return (
    <div>
      <h3>User Settings</h3>
      {/* usernameに関係ない処理: 他の設定(通知設定、テーマ設定など)の管理 */}
      <PrivacySettings />
    </div>
  );
};

const PrivacySettings = () => {
  return (
    <div>
      <h4>Privacy Settings</h4>
      {/* usernameに関係ない処理: プライバシー設定(可視性、ブロックリストなど)の管理 */}
      <UsernameInput />
    </div>
  );
};

const UsernameInput = () => {
  const [username, setUsername] = useAtom(usernameAtom);

  return (
    <div>
      <label htmlFor="username">Change Username: </label>
      <input
        id="username"
        type="text"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      {/* この部分は本当にusernameを使用している */}
    </div>
  );
};

export default App;

インストール方法

環境を作ります。

npx create-next-app@latest

Jotaiをインストールします。

npm install jotai

なお、Jotaiを使ったコード内に"use client";を書き忘れると以下のエラーが出ます。
TypeError: (0 , jotai__WEBPACK_IMPORTED_MODULE_3__.useAtomValue) is not a function

Atomの定義

まず、usernameAtomを作成し、グローバルな状態としてusernameを管理します。

example-jotai.tsx
// グローバルな状態としてusernameのatomを作成
const usernameAtom = atom('JohnDoe');

値以外にも配列やオブジェクトなどを格納することができます。

その他のAtomの定義
import { atom } from 'jotai';

// 1. プリミティブ値
const numberAtom = atom(42);
const stringAtom = atom('Hello, Jotai!');
const booleanAtom = atom(true);

// 2. オブジェクト
const userAtom = atom({ name: 'John', age: 30 });

// 3. 配列
const listAtom = atom([1, 2, 3, 4, 5]);

// 4. 関数
const functionAtom = atom(() => console.log('This is a function atom'));

// 5. null または undefined
const nullAtom = atom(null);
const undefinedAtom = atom(undefined);

// 6. 派生アトム(読み取り専用)
const derivedAtom = atom((get) => get(numberAtom) * 2);

// 7. 書き込み可能な派生アトム
const writableDerivedAtom = atom(
  (get) => get(stringAtom).toUpperCase(),
  (get, set, newValue) => set(stringAtom, newValue.toLowerCase())
);

// 8. 非同期アトム
const asyncAtom = atom(async (get) => {
  const response = await fetch('https://api.example.com/data');
  return response.json();
});

// 9. 複数のアトムを組み合わせたアトム
const combinedAtom = atom((get) => ({
  number: get(numberAtom),
  string: get(stringAtom),
  user: get(userAtom)
}));

// 10. ローカルストレージと連携するアトム
const localStorageAtom = atom(
  localStorage.getItem('savedValue') || '',
  (get, set, newValue) => {
    set(localStorageAtom, newValue);
    localStorage.setItem('savedValue', newValue);
  }
);

Atomの読み取り

ここで、読み取りと更新についてまとめておきます。

// useAtom: 値の読み取りと更新の両方が必要な場合に使用します。
const [username, setUsername] = useAtom(usernameAtom);

// どちらかだけを使うこともできる
const [username] = useAtom(usernameAtom);
const [setUsername] = useAtom(usernameAtom);

// useAtomValue: 値の読み取りのみが必要な場合に使用します。
const username = useAtom(usernameAtom);

// useSetAtom: 値の更新のみが必要な場合に使用します。
const setUsername = useSetAtom(usernameAtom);

今回はusernameを表示するために、useAtomValue(usernameAtom)を使用して現在のusernameを読み取ります。

example-jotai.tsx
const App = () => {
  // 値の読み取りのみなので useAtomValue を使用
  const username = useAtomValue(usernameAtom);

  return (
    <div>
      <h1>Jotai Example</h1>
      <p>Current username: {username}</p>
      {/* usernameに関係ない処理: 例えば他の全体的なアプリの状態管理など */}
      <UserProfile />
    </div>
  );
};

Atomの更新

UsernameInputコンポーネントでuseAtom(usernameAtom)を使用して、usernameの値の読み取りと更新をします。

example-jotai.tsx
const UsernameInput = () => {
  const [username, setUsername] = useAtom(usernameAtom);

  return (
    <div>
      <label htmlFor="username">Change Username: </label>
      <input
        id="username"
        type="text"
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      {/* この部分は本当にusernameを使用している */}
    </div>
  );
};

状態管理ライブラリを使ったメリット

先ほどと違って、中間コンポーネント(UserProfile, UserSettings, PrivacySettings)ではusernamesetUsernameをpropsとして受け取る必要がありません。
引数が空になっています。

本当に必要なところ(App, UsernameInput)のみでusernameを参照できるようになりました。

example-jotai.tsx
const UserProfile = () => {
  return (
    〜省略〜
  );
};

const UserSettings = () => {
  return (
    〜省略〜
  );
};

const PrivacySettings = () => {
  return (
    〜省略〜
  );
};

まとめ

Jotaiを使うことでシンプルにグローバルな状態管理ができました。
今後は対抗馬であるZustandについてまとめたいと思います。

その他の参考記事

https://qiita.com/moritakusan/items/9a5e8c315b2565a02848
https://qiita.com/inabakun/items/6fd19efa28dd92e91e8b

Discussion