12k
All articles

Zustand vs Jotai: React アプリに最適なステート管理ライブラリの選び方

ZustandとJotaiのパフォーマンス、TypeScriptサポート、メンタルモデルを比較し、Reactプロジェクトに適したステート管理ライブラリの選び方を解説する。

OpenReplay Team
OpenReplay Team
Zustand vs Jotai: React アプリに最適なステート管理ライブラリの選び方

Zustand や Jotai を初めて使う方は、まず詳細ガイドをご確認ください:

React のステート管理は、Redux の複雑さを超えて大きく進化しました。小規模から中規模のプロジェクトでは、Zustand や Jotai のような軽量な代替ライブラリが人気を集めています。しかし、どちらを選ぶべきでしょうか?この記事では、同じ開発者(Daishi Kato)によって作られたこれら2つのライブラリを比較し、プロジェクトのニーズに基づいて情報に基づいた決定を下すお手伝いをします。

重要なポイント

  • Zustand は、相互接続されたステートとチーム協力に最適な中央集権的なトップダウンアプローチを使用
  • Jotai は、細かい粒度のリアクティビティと急速に変化するデータに最適なアトミックなボトムアップアプローチを使用
  • 両ライブラリとも軽量で高性能、TypeScript フレンドリー
  • Zustand は複雑なステート関係を持つ大規模アプリケーションに適している場合が多い
  • Jotai は最小限の再レンダリングで独立したステートの断片を必要とするシナリオで優れている

Zustand と Jotai の起源と哲学

両ライブラリは React エコシステムの特定の問題を解決するために作られましたが、異なるアプローチを取っています:

Zustand:トップダウンアプローチ

Zustand(ドイツ語で「状態」の意味)は 2019 年に Redux のより簡単な代替として リリースされました。中央集権的なトップダウンアプローチでステート管理を行います。

import { create } from 'zustand'

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}))

Jotai:ボトムアップアプローチ

Jotai(日本語で「状態」の意味)は 2020 年にリリースされ、Recoil からインスピレーションを得ています。ステートを小さな独立したアトムに分割するアトミックなボトムアップアプローチを使用します。

import { atom, useAtom } from 'jotai'

const countAtom = atom(0)
const doubleCountAtom = atom((get) => get(countAtom) * 2)

メンタルモデル:ステートへの異なるアプローチ

各ライブラリの背後にあるメンタルモデルを理解することは、プロジェクトに適したものを選択するために重要です。

Zustand のストアベースモデル

Zustand は、すべてのステートとアクションを含む単一のストアを使用します。このモデルは Redux を使用したことがある開発者には馴染みがあります:

// ストアの作成
const useUserStore = create((set) => ({
  user: null,
  isLoading: false,
  error: null,
  fetchUser: async (id) => {
    set({ isLoading: true });
    try {
      const response = await fetch(`/api/users/${id}`);
      const user = await response.json();
      set({ user, isLoading: false });
    } catch (error) {
      set({ error, isLoading: false });
    }
  }
}));

// コンポーネントでのストア使用
function Profile({ userId }) {
  const { user, fetchUser } = useUserStore(
    state => ({ user: state.user, fetchUser: state.fetchUser })
  );
  
  useEffect(() => {
    fetchUser(userId);
  }, [userId, fetchUser]);
  
  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}

Jotai のアトミックモデル

Jotai はステートを組み合わせ可能なアトムに分割します。これは React 自体の useState に似ていますが、コンポーネント間でステートを共有する機能があります:

// アトムの作成
const userAtom = atom(null);
const isLoadingAtom = atom(false);
const errorAtom = atom(null);

// 派生アトムの作成
const userStatusAtom = atom(
  (get) => ({
    user: get(userAtom),
    isLoading: get(isLoadingAtom),
    error: get(errorAtom)
  })
);

// アクションアトムの作成
const fetchUserAtom = atom(
  null,
  async (get, set, userId) => {
    set(isLoadingAtom, true);
    try {
      const response = await fetch(`/api/users/${userId}`);
      const user = await response.json();
      set(userAtom, user);
      set(isLoadingAtom, false);
    } catch (error) {
      set(errorAtom, error);
      set(isLoadingAtom, false);
    }
  }
);

// コンポーネントでのアトム使用
function Profile({ userId }) {
  const [{ user, isLoading }] = useAtom(userStatusAtom);
  const [, fetchUser] = useAtom(fetchUserAtom);
  
  useEffect(() => {
    fetchUser(userId);
  }, [userId, fetchUser]);
  
  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}

パフォーマンス考慮事項:Zustand vs Jotai

両ライブラリは高性能になるよう設計されていますが、異なるシナリオに最適化されています。

Zustand のパフォーマンス特性

  • 選択的サブスクリプション:コンポーネントは選択されたステートが変更された時のみ再レンダリング
  • バンドルサイズ:minified + gzipped で約 2.8kB
  • ミドルウェアサポート:パフォーマンス最適化のための組み込みミドルウェア
  • バッチ更新:ステート更新を自動的にバッチ処理

Jotai のパフォーマンス特性

  • 粒度の細かい更新:特定のアトムを使用するコンポーネントのみが再レンダリング
  • バンドルサイズ:コアパッケージで約 3.5kB(minified + gzipped)
  • アトムレベル最適化:どのステート変更が再レンダリングをトリガーするかの細かい制御
  • 派生ステート:計算値を効率的に処理

UI の特定の部分のみに影響する急速に変化するデータの場合、Jotai のアトミックアプローチは多くの場合、より少ない再レンダリングをもたらします。変更頻度が低い相互接続されたステートの場合、Zustand のアプローチがより効率的になることがあります。

TypeScript 統合

両ライブラリは優れた TypeScript サポートを提供しますが、異なるアプローチを取っています。

Zustand と TypeScript

interface BearState {
  bears: number;
  increase: (by: number) => void;
}

const useBearStore = create<BearState>((set) => ({
  bears: 0,
  increase: (by) => set((state) => ({ bears: state.bears + by })),
}));

Jotai と TypeScript

interface User {
  id: string;
  name: string;
}

const userAtom = atom<User | null>(null);
const nameAtom = atom(
  (get) => get(userAtom)?.name || '',
  (get, set, newName: string) => {
    const user = get(userAtom);
    if (user) {
      set(userAtom, { ...user, name: newName });
    }
  }
);

Zustand を選ぶべき場合

以下の場合、Zustand がより良い選択となることが多いです:

  1. 中央集権的なストアが必要な場合:多くのコンポーネントからアクセスと変更が必要な相互接続されたステートを持つアプリケーション

  2. Redux から移行する場合:Zustand の API は Redux ユーザーにとってより馴染みがあり、移行が容易

  3. React 外でのステートアクセスが必要な場合:Zustand は React コンポーネント外でのステートアクセスと変更が可能

  4. チーム協力が優先事項の場合:中央集権的なストアアプローチは大規模チームでの保守が容易

  5. 明示的なステート更新を好む場合:Zustand のアプローチはステート変更をより追跡しやすくする

Jotai を選ぶべき場合

以下の場合、Jotai が優れています:

  1. 細かい粒度のリアクティビティが必要な場合:頻繁に変更される多くの独立したステートの断片を持つ UI

  2. 複雑なフォームを構築する場合:Jotai のアトミックアプローチは独立して検証が必要なフォームフィールドに適している

  3. useState のような API を好む場合:React の組み込みフックに近い API を好む場合

  4. 急速に変化するデータを扱う場合:再レンダリングの最小化が重要なリアルタイムアプリケーション

  5. 派生ステートが必要な場合:Jotai は他のステートに基づく計算値の作成が容易

実世界の実装パターン

両ライブラリで実装される一般的なパターンを見てみましょう。

認証ステート

Zustand の場合:

const useAuthStore = create((set) => ({
  user: null,
  isAuthenticated: false,
  isLoading: false,
  login: async (credentials) => {
    set({ isLoading: true });
    try {
      const user = await loginApi(credentials);
      set({ user, isAuthenticated: true, isLoading: false });
    } catch (error) {
      set({ isLoading: false });
      throw error;
    }
  },
  logout: async () => {
    await logoutApi();
    set({ user: null, isAuthenticated: false });
  }
}));

Jotai の場合:

const userAtom = atom(null);
const isAuthenticatedAtom = atom((get) => !!get(userAtom));
const isLoadingAtom = atom(false);

const loginAtom = atom(
  null,
  async (get, set, credentials) => {
    set(isLoadingAtom, true);
    try {
      const user = await loginApi(credentials);
      set(userAtom, user);
      set(isLoadingAtom, false);
    } catch (error) {
      set(isLoadingAtom, false);
      throw error;
    }
  }
);

const logoutAtom = atom(
  null,
  async (get, set) => {
    await logoutApi();
    set(userAtom, null);
  }
);

フォームステート管理

Zustand の場合:

const useFormStore = create((set) => ({
  values: { name: '', email: '', message: '' },
  errors: {},
  setField: (field, value) => set(state => ({
    values: { ...state.values, [field]: value }
  })),
  validate: () => {
    // バリデーションロジック
    const errors = {};
    set({ errors });
    return Object.keys(errors).length === 0;
  },
  submit: () => {
    // 送信ロジック
  }
}));

Jotai の場合:

const formAtom = atom({ name: '', email: '', message: '' });
const nameAtom = atom(
  (get) => get(formAtom).name,
  (get, set, name) => set(formAtom, { ...get(formAtom), name })
);
const emailAtom = atom(
  (get) => get(formAtom).email,
  (get, set, email) => set(formAtom, { ...get(formAtom), email })
);
const messageAtom = atom(
  (get) => get(formAtom).message,
  (get, set, message) => set(formAtom, { ...get(formAtom), message })
);

const errorsAtom = atom({});
const validateAtom = atom(
  null,
  (get, set) => {
    // バリデーションロジック
    const errors = {};
    set(errorsAtom, errors);
    return Object.keys(errors).length === 0;
  }
);

移行戦略

Redux から Zustand への移行

// Redux
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: state => {
      state.value += 1;
    },
    decrement: state => {
      state.value -= 1;
    }
  }
});

// Zustand
const useCounterStore = create((set) => ({
  value: 0,
  increment: () => set(state => ({ value: state.value + 1 })),
  decrement: () => set(state => ({ value: state.value - 1 }))
}));

Context API から Jotai への移行

// Context API
const CounterContext = createContext();

function CounterProvider({ children }) {
  const [count, setCount] = useState(0);
  return (
    <CounterContext.Provider value={{ count, setCount }}>
      {children}
    </CounterContext.Provider>
  );
}

// Jotai
const countAtom = atom(0);

function App() {
  return <>{/* プロバイダー不要 */}</>;
}

よくある落とし穴とベストプラクティス

Zustand の落とし穴

  1. ストアの断片化:ストアを作りすぎるとステート管理が混乱する可能性
  2. セレクターのメモ化:セレクターのメモ化を忘れると不要な再レンダリングが発生
  3. ミドルウェアの過度な使用:ミドルウェアを追加しすぎるとパフォーマンスに影響

Jotai の落とし穴

  1. アトムの増殖:整理されていないアトムを作りすぎるとコードが追いにくくなる
  2. 循環依存:相互に循環的に依存するアトムの作成
  3. アトムの重複:同じアトムの複数のインスタンスを誤って作成

両方のベストプラクティス

  1. 関連するステートの整理:関連するステートとアクションをまとめる
  2. TypeScript の使用:両ライブラリとも TypeScript の型安全性の恩恵を受ける
  3. ステート構造の文書化:ステートの構成を明確にする
  4. ステートロジックのテスト:ステート管理コードのユニットテストを書く

結論

Zustand と Jotai の選択は、特定のプロジェクト要件によって決まります。Zustand は大規模アプリケーションの複雑で相互接続されたステートに適した中央集権的なアプローチを提供します。Jotai は最小限の再レンダリングで細かい粒度のリアクティビティに優れるアトミックモデルを提供します。両ライブラリとも、TypeScript 互換性を維持しながら Redux の複雑さを大幅に改善する軽量で高性能なソリューションを提供します。

決定を下す際は、チームの異なるステート管理パターンへの慣れ、アプリケーションのパフォーマンス要件、ステート構造を考慮してください。同じアプリケーションで両ライブラリを使用し、それぞれが最も得意とする分野で活用することも可能であることを覚えておいてください。

よくある質問

Zustand と Jotai を一緒に使用できますか?

はい、多くの開発者がグローバルアプリケーションステートには Zustand を、コンポーネントツリー間で共有が必要なコンポーネント固有のステートには Jotai を使用しています。

これらのライブラリは大規模アプリケーションに適していますか?

はい、適切に使用すれば両方とも大規模アプリケーションにスケールできます。Zustand は中央集権的なアプローチにより、大規模チーム環境での保守が容易かもしれません。

これらは Redux とどう比較されますか?

両方とも Redux よりも大幅に軽量でシンプルです。Zustand は哲学的に Redux に近いですが、ボイラープレートがはるかに少ないです。Jotai はアトミックステートに焦点を当てた全く異なるアプローチを取っています。

アプリをプロバイダーでラップする必要がありますか?

Zustand はデフォルトでプロバイダーを必要としません。Jotai はグローバルアトムにはプロバイダーなしで使用できますが、スコープ付きステートにはプロバイダーを提供します。

これらのライブラリは Next.js や他のフレームワークで使用できますか?

はい、両ライブラリとも Next.js や他の React フレームワークで良好に動作します。サーバーサイドレンダリングサポートのための特定のユーティリティを提供しています。

Listen to your bugs 🧘, with OpenReplay

See how users use your app and resolve issues fast.
Loved by thousands of developers

We use cookies to improve your experience. By using our site, you accept cookies.