T-CREATOR

Jotai のリアクティブ思考法:コンポーネントから状態を切り離す設計哲学

Jotai のリアクティブ思考法:コンポーネントから状態を切り離す設計哲学

React アプリケーションにおける状態管理は、アプリケーションが成長するにつれて複雑になりがちです。コンポーネントと状態が密結合すると、再利用性が低下し、テストも困難になります。Jotai は、アトミックな状態管理を通じてこの課題を解決し、コンポーネントから状態を切り離す新しい設計哲学を提供しています。

この記事では、Jotai のリアクティブ思考法を深く掘り下げ、コンポーネントと状態を分離することでどのように保守性と拡張性が向上するのかを解説します。

背景

従来の状態管理の問題点

React における従来の状態管理では、useStateuseReducer を使ってコンポーネント内に状態を保持することが一般的でした。しかし、この方法にはいくつかの課題があります。

状態がコンポーネントに紐づくと、以下のような問題が発生します:

  • 状態の共有が困難になり、Props のバケツリレーが発生する
  • コンポーネントの責務が曖昧になり、ビジネスロジックと UI ロジックが混在する
  • テスト時にコンポーネントをレンダリングしなければ状態の動作を確認できない

以下の図は、従来の状態管理におけるコンポーネント間の依存関係を示しています。

mermaidflowchart TD
  App["App コンポーネント"] -->|Props 渡し| Header["Header コンポーネント"]
  App -->|Props 渡し| Main["Main コンポーネント"]
  App -->|Props 渡し| Sidebar["Sidebar コンポーネント"]
  Main -->|Props 渡し| List["List コンポーネント"]
  Main -->|Props 渡し| Detail["Detail コンポーネント"]

  App -.->|useState で管理| state[("状態<br/>user, items, filters")]

  style state fill:#f9f,stroke:#333,stroke-width:2px

この図から分かるように、状態が App コンポーネントに集中し、子コンポーネントへ Props として伝播しています。これにより、中間コンポーネントが不要な Props を受け取る「Props ドリリング」が発生します。

Jotai が目指す設計哲学

Jotai は、状態をアトム(atom)という最小単位に分割し、コンポーネントから完全に独立させることで、この問題を解決します。

Jotai の設計哲学は以下の 3 つの原則に基づいています:

#原則説明
1アトミック設計状態を最小単位(atom)に分割し、必要な箇所でのみ使用
2ボトムアップ構成小さな atom を組み合わせて複雑な状態を構築
3リアクティブ依存atom 間の依存関係が自動的に追跡され、変更が伝播

この設計により、状態とコンポーネントが疎結合になり、それぞれが独立して進化できるようになります。

課題

コンポーネントと状態の密結合問題

従来の React アプリケーションでは、コンポーネントと状態が密結合することで、以下のような具体的な課題が発生します。

課題 1:状態の再利用性の低下

コンポーネント内で定義された状態は、そのコンポーネントでしか使用できません。同じ状態を別のコンポーネントで使いたい場合、状態を親コンポーネントに移動させる必要があります。

typescript// ❌ 悪い例:コンポーネントに状態が密結合
function UserProfile() {
  // この状態は UserProfile でしか使えない
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUser();
  }, []);

  // ...
}

課題 2:テストの複雑化

状態がコンポーネント内にある場合、状態のロジックをテストするためにコンポーネント全体をレンダリングする必要があります。

typescript// ❌ テストが複雑:UI とロジックが分離できない
test('ユーザー情報の取得', async () => {
  // コンポーネントをレンダリングしないとテストできない
  const { getByText } = render(<UserProfile />);

  await waitFor(() => {
    expect(getByText(/ユーザー名/)).toBeInTheDocument();
  });
});

課題 3:状態の依存関係の管理

複数の状態が相互に依存する場合、useEffect を使って手動で同期を取る必要があり、コードが複雑になります。

typescript// ❌ 悪い例:状態間の依存を手動管理
function ProductList() {
  const [products, setProducts] = useState([]);
  const [filter, setFilter] = useState('all');
  const [filteredProducts, setFilteredProducts] = useState(
    []
  );

  // 依存関係を手動で管理
  useEffect(() => {
    const filtered = products.filter((p) => {
      if (filter === 'all') return true;
      return p.category === filter;
    });
    setFilteredProducts(filtered);
  }, [products, filter]); // 依存配列の管理が必要

  // ...
}

以下の図は、状態間の依存関係を手動管理する場合の複雑さを示しています。

mermaidflowchart TD
  products["products 状態"] -->|useEffect で監視| effect1["useEffect"]
  filter["filter 状態"] -->|useEffect で監視| effect1
  effect1 -->|手動で計算| filtered["filteredProducts 状態"]

  products -->|useEffect で監視| effect2["useEffect(別の処理)"]
  effect2 -->|手動で計算| count["productCount 状態"]

  filtered -->|useEffect で監視| effect3["useEffect(更に別の処理)"]
  count -->|useEffect で監視| effect3
  effect3 -->|手動で計算| summary["summary 状態"]

  style effect1 fill:#faa,stroke:#333
  style effect2 fill:#faa,stroke:#333
  style effect3 fill:#faa,stroke:#333

この図から、状態の依存関係が増えるにつれて useEffect が増殖し、管理が複雑になることが分かります。

グローバル状態管理の課題

Redux のようなグローバル状態管理ライブラリを使用すると、状態をコンポーネントから分離できますが、別の課題が生じます。

#課題詳細
1ボイラープレートアクション、リデューサー、型定義など大量のコードが必要
2パフォーマンスグローバル状態の一部が更新されると、関連するすべてのコンポーネントが再レンダリング
3学習コスト独自の概念(Store、Dispatch、Selector など)を学ぶ必要がある

これらの課題に対して、Jotai はシンプルかつ効率的な解決策を提供します。

解決策

Jotai のアトミック設計

Jotai は、状態を「atom」という最小単位に分割し、コンポーネントから完全に独立させます。これにより、状態の再利用性とテスタビリティが大幅に向上します。

Atom による状態の定義

Jotai では、状態を atom として定義します。atom はコンポーネントの外部で定義され、どのコンポーネントからでもアクセスできます。

typescript// atoms/userAtoms.ts
import { atom } from 'jotai';

// ユーザー情報を保持する atom
export const userAtom = atom(null);

// ローディング状態を保持する atom
export const loadingAtom = atom(false);

// エラー状態を保持する atom
export const errorAtom = atom(null);

このように、状態をファイル単位で管理することで、状態の所在が明確になり、コンポーネントから分離されます。

コンポーネントでの atom 使用

定義した atom は、useAtom フックを使ってコンポーネント内で読み書きできます。

typescript// components/UserProfile.tsx
import { useAtom } from 'jotai';
import {
  userAtom,
  loadingAtom,
  errorAtom,
} from '../atoms/userAtoms';

function UserProfile() {
  // atom を使用(useState と同じインターフェース)
  const [user, setUser] = useAtom(userAtom);
  const [loading, setLoading] = useAtom(loadingAtom);
  const [error, setError] = useAtom(errorAtom);

  // コンポーネントはビューロジックに集中
  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  if (!user) return <div>ユーザーが見つかりません</div>;

  return <div>{user.name}</div>;
}

コンポーネントは状態の定義を持たず、atom を通じて状態にアクセスするだけです。これにより、コンポーネントの責務が明確になります。

派生状態(Derived Atoms)によるリアクティブな依存関係

Jotai の最も強力な機能の 1 つが、派生 atom(Derived Atoms)です。他の atom に依存する atom を定義することで、自動的にリアクティブな依存関係を構築できます。

読み取り専用の派生 atom

他の atom から計算された値を返す読み取り専用の atom を定義できます。

typescript// atoms/productAtoms.ts
import { atom } from 'jotai';

// 基本 atom
export const productsAtom = atom([]);
export const filterAtom = atom('all');
typescript// 派生 atom:フィルタリングされた商品リスト
export const filteredProductsAtom = atom((get) => {
  const products = get(productsAtom); // productsAtom の値を取得
  const filter = get(filterAtom); // filterAtom の値を取得

  if (filter === 'all') return products;
  return products.filter((p) => p.category === filter);
});

この派生 atom は、productsAtom または filterAtom が更新されると自動的に再計算されます。useEffect による手動管理は不要です。

読み書き可能な派生 atom

派生 atom は読み取りだけでなく、書き込みロジックも持つことができます。

typescript// atoms/counterAtoms.ts
import { atom } from 'jotai';

const countAtom = atom(0);

// 読み書き可能な派生 atom
export const doubleCountAtom = atom(
  // 読み取り:count の 2 倍を返す
  (get) => get(countAtom) * 2,

  // 書き込み:元の count を更新
  (get, set, newValue) => {
    set(countAtom, newValue / 2);
  }
);

この atom を使用すると、doubleCountAtom に値を設定すると自動的に countAtom が更新されます。

以下の図は、Jotai における atom 間のリアクティブな依存関係を示しています。

mermaidflowchart LR
  products["productsAtom<br/>(基本 atom)"]
  filter["filterAtom<br/>(基本 atom)"]

  products -->|自動追跡| filtered["filteredProductsAtom<br/>(派生 atom)"]
  filter -->|自動追跡| filtered

  filtered -->|自動追跡| count["productCountAtom<br/>(派生 atom)"]

  count -->|自動追跡| summary["summaryAtom<br/>(派生 atom)"]
  filtered -->|自動追跡| summary

  style products fill:#9cf,stroke:#333
  style filter fill:#9cf,stroke:#333
  style filtered fill:#9f9,stroke:#333
  style count fill:#9f9,stroke:#333
  style summary fill:#9f9,stroke:#333

この図から分かるように、基本 atom(青)が更新されると、それに依存する派生 atom(緑)が自動的に再計算されます。開発者は依存関係を手動で管理する必要がありません。

非同期処理の統合

Jotai は非同期処理もシームレスに統合できます。Promise を返す atom を定義するだけで、Suspense と組み合わせて使用できます。

非同期 atom の定義

typescript// atoms/userAtoms.ts
import { atom } from 'jotai';

const userIdAtom = atom(1);

// 非同期データを取得する atom
export const userAtom = atom(async (get) => {
  const userId = get(userIdAtom);

  // API からデータを取得
  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok)
    throw new Error('ユーザーの取得に失敗しました');

  return response.json();
});

この atom は Promise を返すため、React の Suspense と組み合わせて使用できます。

Suspense との統合

typescript// components/UserProfile.tsx
import { Suspense } from 'react';
import { useAtomValue } from 'jotai';
import { userAtom } from '../atoms/userAtoms';

function UserProfile() {
  // 非同期 atom を読み取り専用で使用
  const user = useAtomValue(userAtom);

  return <div>{user.name}</div>;
}
typescript// components/App.tsx
function App() {
  return (
    <Suspense fallback={<div>読み込み中...</div>}>
      <UserProfile />
    </Suspense>
  );
}

userAtom が Promise を解決するまで、Suspense の fallback が表示されます。エラーハンドリングも Error Boundary で統一的に処理できます。

Atom の組織化パターン

状態をコンポーネントから分離すると、atom の組織化が重要になります。以下は推奨されるパターンです。

ドメイン別の atom 管理

typescript// atoms/user/
//   - userAtoms.ts      # ユーザー関連の atom
//   - authAtoms.ts      # 認証関連の atom
//
// atoms/product/
//   - productAtoms.ts   # 商品関連の atom
//   - cartAtoms.ts      # カート関連の atom

ドメインごとにファイルを分割することで、状態の所在が明確になり、チーム開発でも競合が減少します。

ファミリー atom によるパラメータ化

同じ構造の状態を複数管理する場合、atom ファミリーを使用します。

typescript// atoms/todoAtoms.ts
import { atomFamily } from 'jotai/utils';

// ID ごとに異なる todo を管理
export const todoAtomFamily = atomFamily((id: number) =>
  atom({
    id,
    title: '',
    completed: false,
  })
);
typescript// components/TodoItem.tsx
function TodoItem({ id }: { id: number }) {
  // ID に応じた atom を取得
  const [todo, setTodo] = useAtom(todoAtomFamily(id));

  return (
    <div>
      <input
        value={todo.title}
        onChange={(e) =>
          setTodo({ ...todo, title: e.target.value })
        }
      />
    </div>
  );
}

atom ファミリーにより、動的な数の状態を効率的に管理できます。

具体例

実践例:ショッピングカートの実装

Jotai を使ってショッピングカート機能を実装することで、リアクティブ思考法の実践的な活用方法を見ていきます。

Step 1:基本 atom の定義

まず、商品データとカート内のアイテムを管理する基本 atom を定義します。

typescript// atoms/shopAtoms.ts
import { atom } from 'jotai';

// 商品の型定義
interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

// カートアイテムの型定義
interface CartItem {
  productId: number;
  quantity: number;
}
typescript// 全商品リスト(実際は API から取得)
export const productsAtom = atom<Product[]>([
  {
    id: 1,
    name: 'ノート PC',
    price: 120000,
    category: 'electronics',
  },
  {
    id: 2,
    name: 'マウス',
    price: 3000,
    category: 'electronics',
  },
  {
    id: 3,
    name: '本棚',
    price: 15000,
    category: 'furniture',
  },
]);

// カート内のアイテム
export const cartItemsAtom = atom<CartItem[]>([]);

これらの atom は状態の基礎となるデータを保持します。

Step 2:派生 atom でビジネスロジックを実装

次に、カートの合計金額や商品詳細を計算する派生 atom を定義します。

typescript// カート内の商品詳細(商品情報とカートアイテムを結合)
export const cartDetailsAtom = atom((get) => {
  const products = get(productsAtom);
  const cartItems = get(cartItemsAtom);

  return cartItems.map((item) => {
    // 商品情報を検索
    const product = products.find(
      (p) => p.id === item.productId
    );

    return {
      ...item,
      product,
      subtotal: product ? product.price * item.quantity : 0,
    };
  });
});
typescript// カートの合計金額を計算
export const cartTotalAtom = atom((get) => {
  const cartDetails = get(cartDetailsAtom);

  return cartDetails.reduce(
    (total, item) => total + item.subtotal,
    0
  );
});
typescript// カート内の商品数を計算
export const cartItemCountAtom = atom((get) => {
  const cartItems = get(cartItemsAtom);

  return cartItems.reduce(
    (count, item) => count + item.quantity,
    0
  );
});

これらの派生 atom は、cartItemsAtomproductsAtom が更新されると自動的に再計算されます。

以下の図は、ショッピングカートにおける atom 間の依存関係を示しています。

mermaidflowchart TD
  products["productsAtom<br/>商品マスタ"]
  cart["cartItemsAtom<br/>カート内容"]

  products -->|結合| details["cartDetailsAtom<br/>商品詳細付きカート"]
  cart -->|結合| details

  details -->|集計| total["cartTotalAtom<br/>合計金額"]
  cart -->|集計| count["cartItemCountAtom<br/>商品数"]

  details -.->|表示| comp1["CartList コンポーネント"]
  total -.->|表示| comp2["CartSummary コンポーネント"]
  count -.->|表示| comp3["CartBadge コンポーネント"]

  style products fill:#9cf,stroke:#333
  style cart fill:#9cf,stroke:#333
  style details fill:#9f9,stroke:#333
  style total fill:#9f9,stroke:#333
  style count fill:#9f9,stroke:#333
  style comp1 fill:#ff9,stroke:#333
  style comp2 fill:#ff9,stroke:#333
  style comp3 fill:#ff9,stroke:#333

図の要点:

  • 基本 atom(青)から派生 atom(緑)が自動計算される
  • コンポーネント(黄)は必要な atom のみを購読する
  • 状態の変更が依存する atom に自動伝播する

Step 3:カート操作の atom を実装

カートへの商品追加・削除などの操作を、書き込み可能な派生 atom として実装します。

typescript// カートに商品を追加する atom
export const addToCartAtom = atom(
  null, // 読み取り不要
  (get, set, productId: number) => {
    const cartItems = get(cartItemsAtom);

    // 既にカートにある場合は数量を増やす
    const existingItem = cartItems.find(
      (item) => item.productId === productId
    );

    if (existingItem) {
      set(
        cartItemsAtom,
        cartItems.map((item) =>
          item.productId === productId
            ? { ...item, quantity: item.quantity + 1 }
            : item
        )
      );
    } else {
      // 新規追加
      set(cartItemsAtom, [
        ...cartItems,
        { productId, quantity: 1 },
      ]);
    }
  }
);
typescript// カートから商品を削除する atom
export const removeFromCartAtom = atom(
  null,
  (get, set, productId: number) => {
    const cartItems = get(cartItemsAtom);
    set(
      cartItemsAtom,
      cartItems.filter(
        (item) => item.productId !== productId
      )
    );
  }
);
typescript// 商品の数量を更新する atom
export const updateQuantityAtom = atom(
  null,
  (
    get,
    set,
    {
      productId,
      quantity,
    }: { productId: number; quantity: number }
  ) => {
    const cartItems = get(cartItemsAtom);

    if (quantity <= 0) {
      // 数量が 0 以下なら削除
      set(
        cartItemsAtom,
        cartItems.filter(
          (item) => item.productId !== productId
        )
      );
    } else {
      set(
        cartItemsAtom,
        cartItems.map((item) =>
          item.productId === productId
            ? { ...item, quantity }
            : item
        )
      );
    }
  }
);

これらの atom により、カート操作のロジックがコンポーネントから完全に分離されます。

Step 4:コンポーネントでの使用

定義した atom をコンポーネントで使用します。コンポーネントは UI の描画と atom の呼び出しに集中します。

typescript// components/ProductList.tsx
import { useAtomValue, useSetAtom } from 'jotai';
import {
  productsAtom,
  addToCartAtom,
} from '../atoms/shopAtoms';

function ProductList() {
  const products = useAtomValue(productsAtom); // 読み取り専用
  const addToCart = useSetAtom(addToCartAtom); // 書き込み専用

  return (
    <div>
      {products.map((product) => (
        <div key={product.id}>
          <h3>{product.name}</h3>
          <p>¥{product.price.toLocaleString()}</p>
          <button onClick={() => addToCart(product.id)}>
            カートに追加
          </button>
        </div>
      ))}
    </div>
  );
}
typescript// components/CartSummary.tsx
import { useAtomValue } from 'jotai';
import {
  cartTotalAtom,
  cartItemCountAtom,
} from '../atoms/shopAtoms';

function CartSummary() {
  const total = useAtomValue(cartTotalAtom);
  const itemCount = useAtomValue(cartItemCountAtom);

  return (
    <div>
      <p>商品数: {itemCount} 点</p>
      <p>合計金額: ¥{total.toLocaleString()}</p>
    </div>
  );
}
typescript// components/CartList.tsx
import { useAtomValue, useSetAtom } from 'jotai';
import {
  cartDetailsAtom,
  updateQuantityAtom,
  removeFromCartAtom,
} from '../atoms/shopAtoms';

function CartList() {
  const cartDetails = useAtomValue(cartDetailsAtom);
  const updateQuantity = useSetAtom(updateQuantityAtom);
  const removeFromCart = useSetAtom(removeFromCartAtom);

  return (
    <div>
      {cartDetails.map((item) => (
        <div key={item.productId}>
          <h4>{item.product?.name}</h4>
          <input
            type='number'
            value={item.quantity}
            onChange={(e) =>
              updateQuantity({
                productId: item.productId,
                quantity: parseInt(e.target.value) || 0,
              })
            }
          />
          <p>小計: ¥{item.subtotal.toLocaleString()}</p>
          <button
            onClick={() => removeFromCart(item.productId)}
          >
            削除
          </button>
        </div>
      ))}
    </div>
  );
}

各コンポーネントは必要な atom のみを使用し、状態のロジックを一切持ちません。これにより、コンポーネントの責務が明確になり、テストも容易になります。

Step 5:状態のテスト

状態がコンポーネントから分離されているため、atom 単体でテストできます。

typescript// atoms/__tests__/shopAtoms.test.ts
import { describe, it, expect } from 'vitest';
import { createStore } from 'jotai';
import {
  cartItemsAtom,
  cartTotalAtom,
  addToCartAtom,
  productsAtom
} from '../shopAtoms';

describe('ショッピングカート atom', () => {
  it('商品を追加すると合計金額が更新される', () => {
    const store = createStore();

    // 初期商品データを設定
    store.set(productsAtom, [
      { id: 1, name: 'テスト商品', price: 1000, category: 'test' }
    ]);

    // 商品を追加
    store.set(addToCartAtom, 1);

    // 合計金額を確認
    const total = store.get(cartTotalAtom);
    expect(total).toBe(1000);
  });
typescript  it('同じ商品を追加すると数量が増える', () => {
    const store = createStore();

    store.set(productsAtom, [
      { id: 1, name: 'テスト商品', price: 1000, category: 'test' }
    ]);

    // 同じ商品を 2 回追加
    store.set(addToCartAtom, 1);
    store.set(addToCartAtom, 1);

    const cartItems = store.get(cartItemsAtom);
    expect(cartItems).toHaveLength(1);
    expect(cartItems[0].quantity).toBe(2);

    const total = store.get(cartTotalAtom);
    expect(total).toBe(2000);
  });
});

コンポーネントをレンダリングすることなく、状態のロジックを直接テストできます。これにより、テストの実行速度が向上し、テストコードもシンプルになります。

実装のメリット総括

この実装により、以下のメリットが得られます:

#メリット詳細
1状態の再利用性atom は複数のコンポーネントから使用可能
2コンポーネントの簡潔性UI ロジックのみに集中でき、状態管理ロジックは atom に集約
3テスタビリティ状態を独立してテストでき、モックが不要
4パフォーマンス必要な atom のみを購読するため、不要な再レンダリングが発生しない
5保守性ビジネスロジックが atom に集約され、変更箇所が明確

まとめ

Jotai のリアクティブ思考法は、状態をコンポーネントから完全に切り離すことで、React アプリケーションの設計に新しいアプローチをもたらします。

この記事で解説した主要なポイントは以下の通りです:

設計哲学の本質

Jotai は、状態を最小単位(atom)に分割し、ボトムアップで組み合わせるアプローチを採用しています。これにより、状態とコンポーネントが疎結合になり、それぞれが独立して進化できるようになります。従来の Props ドリリングや useEffect による依存管理から解放され、より宣言的で直感的なコードが実現します。

リアクティブな依存関係

派生 atom を使用することで、atom 間の依存関係を自動的に追跡し、変更を伝播させることができます。手動で useEffect を記述する必要がなく、依存配列の管理ミスも発生しません。これにより、コードの可読性と保守性が大幅に向上するでしょう。

テスタビリティの向上

状態がコンポーネントから分離されているため、状態のロジックを独立してテストできます。コンポーネントのレンダリングやモックの作成が不要になり、テストコードがシンプルになります。これは、継続的なリファクタリングと品質保証に大きく貢献します。

実践的な活用

ショッピングカートの実装例で示したように、Jotai は実際のアプリケーション開発において、状態管理の複雑さを大幅に軽減します。基本 atom と派生 atom を組み合わせることで、複雑なビジネスロジックを宣言的に表現でき、コンポーネントは UI に集中できるようになります。

Jotai のリアクティブ思考法を採用することで、React アプリケーションはよりスケーラブルで保守しやすいものになるはずです。状態とコンポーネントの分離は、単なる技術的な改善ではなく、アプリケーション設計における根本的なパラダイムシフトと言えるでしょう。

ぜひ、あなたのプロジェクトでも Jotai を試してみてください。小さな atom から始めて、徐々にリアクティブな設計思想に慣れていくことをお勧めします。

関連リンク