T-CREATOR

Jotai ビジネスロジックを UI から分離する「アクション atom」という考え方(Write-only atoms 活用術)

Jotai ビジネスロジックを UI から分離する「アクション atom」という考え方(Write-only atoms 活用術)

React アプリケーションの開発において、状態管理は避けて通れない重要な要素です。しかし、プロジェクトが成長するにつれて、UI コンポーネントとビジネスロジックが密結合してしまい、保守性やテスト性が低下してしまう経験をお持ちではないでしょうか。

そんな課題を解決する革新的なアプローチが、Jotai のWrite-only atomsを活用した「アクション atom」という考え方です。この手法を使うことで、ビジネスロジックを UI から美しく分離し、より保守性の高い React アプリケーションを構築できるようになります。

本記事では、従来の状態管理の課題から始まり、アクション atom の概念、そして実践的な実装方法まで、段階的に解説していきます。きっと、あなたの React 開発における新たな発見と、コードの品質向上につながることでしょう。

背景

従来の React 状態管理の課題

React アプリケーションの開発では、状態管理が複雑になりがちです。特に、以下のような問題が頻繁に発生します。

typescript// 典型的な問題のあるコンポーネント例
const UserProfile = () => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // ビジネスロジックがコンポーネント内に散在
  const updateUserProfile = async (userData: UpdateUserData) => {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch('/api/user', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(userData)
      });

      if (!response.ok) {
        throw new Error('Failed to update user profile');
      }

      const updatedUser = await response.json();
      setUser(updatedUser);
      // さらに他の関連状態も更新...
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    // JSX...
  );
};

この例では、ビジネスロジック(API 呼び出し、エラーハンドリング、状態更新)がコンポーネント内に直接記述されています。これは一見問題なさそうですが、実際にはいくつかの深刻な問題を抱えています。

UI とビジネスロジックが密結合になる問題

上記のようなコードが抱える問題点を整理してみましょう。

#問題点影響
1ビジネスロジックの散在同じようなロジックが複数のコンポーネントに重複
2テストの困難さUI とロジックが密結合でユニットテストが書きにくい
3再利用性の低さ他のコンポーネントで同じロジックを使いたい場合の対応が困難
4責務の曖昧さコンポーネントが「表示」と「処理」の両方を担当

この問題は、プロジェクトの規模が大きくなるほど顕著になります。

Jotai の特徴と Write-only atoms の位置づけ

Jotai は、原子的な状態管理を提供する React 向けのライブラリです。従来の状態管理ライブラリとは異なり、bottom-upなアプローチを採用しています。

typescript// Jotaiの基本的な atom の例
import { atom } from 'jotai';

// 読み書き可能な atom
const countAtom = atom(0);

// 読み込み専用の atom(derived state)
const doubleCountAtom = atom((get) => get(countAtom) * 2);

// 書き込み専用の atom(これがアクション atom)
const incrementAtom = atom(
  null, // 初期値はnull(読み込み不可)
  (get, set) => {
    const current = get(countAtom);
    set(countAtom, current + 1);
  }
);

Write-only atomsは、Jotai の特徴的な機能の一つです。これらの atom は:

  • 読み込みができない(初期値が null
  • 書き込み(アクション)のみが可能
  • ビジネスロジックをカプセル化する役割を果たす

この仕組みにより、アクション atomという概念が生まれました。アクション atom は、単なる状態の更新ではなく、ビジネスロジックそのものを表現する手段となります。

課題

コンポーネントが肥大化する問題

従来の React 開発では、コンポーネントが以下のような責務を一手に引き受けてしまいがちです:

typescriptconst ComplexComponent = () => {
  // 1. 状態管理
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [filters, setFilters] = useState({});

  // 2. 副作用の管理
  useEffect(() => {
    fetchInitialData();
  }, []);

  // 3. ビジネスロジック
  const fetchInitialData = async () => {
    // 複雑な処理...
  };

  const applyFilters = (newFilters) => {
    // フィルタリング処理...
  };

  const handleSubmit = async (formData) => {
    // 送信処理...
  };

  // 4. イベントハンドラー
  const handleClick = () => {
    // クリック処理...
  };

  // 5. レンダリング
  return (
    // 複雑なJSX...
  );
};

このようなコンポーネントは、以下の問題を引き起こします:

  • 可読性の低下: 一つのファイルに多くの責務が混在
  • 保守性の悪化: 変更時の影響範囲が予測しにくい
  • 再利用性の欠如: 他の場所で同じロジックを使いたい時の対応が困難

テストが困難になる状況

UI とビジネスロジックが密結合した状態では、テストの作成が非常に困難になります。

typescript// テストしにくいコンポーネントの例
const UserDashboard = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(false);

  const deleteUser = async (userId: string) => {
    setLoading(true);
    try {
      await fetch(`/api/users/${userId}`, {
        method: 'DELETE',
      });
      setUsers((prev) =>
        prev.filter((user) => user.id !== userId)
      );
    } catch (error) {
      console.error('Delete failed:', error);
      // エラーハンドリング...
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      {users.map((user) => (
        <div key={user.id}>
          {user.name}
          <button onClick={() => deleteUser(user.id)}>
            削除
          </button>
        </div>
      ))}
    </div>
  );
};

このコンポーネントをテストする際の問題点:

  1. DOM 要素への依存: ビジネスロジックが DOM 要素と結合している
  2. 副作用の管理: API 呼び出しや state 更新のモックが複雑
  3. テストケースの分離困難: UI テストとロジックテストが混在

実際に、このようなコンポーネントのテストを書こうとすると、以下のようなエラーに遭遇することがあります:

javascriptError: Cannot read properties of undefined (reading 'map')
    at UserDashboard (/src/components/UserDashboard.tsx:15:21)
    at renderWithHooks (/node_modules/react-dom/cjs/react-dom.development.js:15486:18)

これは、テスト環境での state 初期化の問題や、非同期処理の待機漏れが原因です。

再利用性が低くなる現状

ビジネスロジックがコンポーネント内に閉じ込められていると、他のコンポーネントで同じロジックを使いたい場合に問題が発生します。

typescript// 同じようなロジックが複数のコンポーネントに散在
const UserList = () => {
  const [users, setUsers] = useState([]);

  const fetchUsers = async () => {
    // ユーザー取得ロジック(重複)
    const response = await fetch('/api/users');
    const data = await response.json();
    setUsers(data);
  };

  // その他のUI関連コード...
};

const UserProfile = () => {
  const [user, setUser] = useState(null);

  const fetchUser = async (id: string) => {
    // 似たようなロジック(重複)
    const response = await fetch(`/api/users/${id}`);
    const data = await response.json();
    setUser(data);
  };

  // その他のUI関連コード...
};

この状況では、API エンドポイントが変更されたり、エラーハンドリングを統一したい場合に、複数のファイルを修正する必要があります。

解決策

アクション atom の概念と設計思想

アクション atom は、Write-only atomsの特性を活かして、ビジネスロジックを UI から完全に分離する設計パターンです。

typescriptimport { atom } from 'jotai';

// 状態を管理するatom
const usersAtom = atom<User[]>([]);
const loadingAtom = atom(false);
const errorAtom = atom<string | null>(null);

// アクション atom(Write-only)
const fetchUsersAtom = atom(
  null, // 読み込み不可
  async (get, set) => {
    set(loadingAtom, true);
    set(errorAtom, null);

    try {
      const response = await fetch('/api/users');
      if (!response.ok) {
        throw new Error(
          `HTTP error! status: ${response.status}`
        );
      }
      const users = await response.json();
      set(usersAtom, users);
    } catch (error) {
      set(errorAtom, error.message);
    } finally {
      set(loadingAtom, false);
    }
  }
);

この設計により、以下の利点が得られます:

  • 責務の明確化: アクション atom はビジネスロジックのみを担当
  • 再利用性: 複数のコンポーネントから同じアクションを実行可能
  • テスト性: ビジネスロジックを独立してテスト可能

Write-only atoms の仕組みと活用方法

Write-only atoms は、Jotai の独特な機能です。通常の atom との違いを理解することが重要です。

typescript// 通常の atom(読み書き可能)
const countAtom = atom(0);

// 読み込み専用 atom(derived state)
const doubleCountAtom = atom((get) => get(countAtom) * 2);

// 書き込み専用 atom(アクション atom)
const incrementAtom = atom(
  null, // 初期値null = 読み込み不可
  (get, set) => {
    const current = get(countAtom);
    set(countAtom, current + 1);
  }
);

アクション atom の特徴:

  1. 読み込み不可: get(actionAtom) はエラーになる
  2. 副作用の実行: set(actionAtom) で副作用を実行
  3. パラメータ受け取り: set(actionAtom, params) でパラメータを受け取り可能
typescript// パラメータを受け取るアクション atom
const updateUserAtom = atom(
  null,
  async (get, set, userData: UpdateUserData) => {
    set(loadingAtom, true);

    try {
      const response = await fetch('/api/user', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(userData),
      });

      if (!response.ok) {
        throw new Error('Failed to update user');
      }

      const updatedUser = await response.json();
      set(userAtom, updatedUser);
    } catch (error) {
      set(errorAtom, error.message);
    } finally {
      set(loadingAtom, false);
    }
  }
);

ビジネスロジック分離のベストプラクティス

アクション atom を効果的に活用するためのベストプラクティスをご紹介します。

1. 命名規則の統一

typescript// 悪い例:何をするかわからない
const actionAtom = atom(null, (get, set) => {
  // 処理...
});

// 良い例:動詞で始まる明確な名前
const fetchUsersAtom = atom(null, async (get, set) => {
  // ユーザー取得処理...
});

const createUserAtom = atom(
  null,
  async (get, set, userData) => {
    // ユーザー作成処理...
  }
);

const deleteUserAtom = atom(
  null,
  async (get, set, userId) => {
    // ユーザー削除処理...
  }
);

2. エラーハンドリングの統一

typescript// エラーハンドリングを統一したアクション atom
const apiActionAtom = atom(
  null,
  async (get, set, { endpoint, method, data }) => {
    set(loadingAtom, true);
    set(errorAtom, null);

    try {
      const response = await fetch(endpoint, {
        method,
        headers: { 'Content-Type': 'application/json' },
        body: data ? JSON.stringify(data) : undefined,
      });

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(
          errorData.message || `HTTP ${response.status}`
        );
      }

      return await response.json();
    } catch (error) {
      console.error('API Error:', error);
      set(errorAtom, error.message);
      throw error; // 必要に応じて再スロー
    } finally {
      set(loadingAtom, false);
    }
  }
);

3. 関連する atom をグループ化

typescript// atoms/userAtoms.ts
export const userAtom = atom<User | null>(null);
export const usersAtom = atom<User[]>([]);
export const userLoadingAtom = atom(false);
export const userErrorAtom = atom<string | null>(null);

// アクション atoms
export const fetchUserAtom = atom(
  null,
  async (get, set, userId: string) => {
    // ユーザー取得処理...
  }
);

export const updateUserAtom = atom(
  null,
  async (get, set, userData: UpdateUserData) => {
    // ユーザー更新処理...
  }
);

この組織化により、関連する状態とアクションが一箇所にまとまり、保守性が向上します。

具体例

シンプルなカウンターアプリでの実装

まずは、最もシンプルな例として、カウンターアプリケーションでアクション atom を実装してみましょう。

従来のアプローチ

typescript// 従来のReactコンポーネントの実装
const Counter = () => {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  const increment = () => {
    setCount((prev) => prev + step);
  };

  const decrement = () => {
    setCount((prev) => prev - step);
  };

  const reset = () => {
    setCount(0);
  };

  return (
    <div>
      <h2>カウント: {count}</h2>
      <input
        type='number'
        value={step}
        onChange={(e) => setStep(Number(e.target.value))}
      />
      <button onClick={increment}>+{step}</button>
      <button onClick={decrement}>-{step}</button>
      <button onClick={reset}>リセット</button>
    </div>
  );
};

アクション atom を使った実装

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

// 状態管理用のatom
export const countAtom = atom(0);
export const stepAtom = atom(1);

// アクション atoms
export const incrementAtom = atom(null, (get, set) => {
  const currentCount = get(countAtom);
  const currentStep = get(stepAtom);
  set(countAtom, currentCount + currentStep);
});

export const decrementAtom = atom(null, (get, set) => {
  const currentCount = get(countAtom);
  const currentStep = get(stepAtom);
  set(countAtom, currentCount - currentStep);
});

export const resetAtom = atom(null, (get, set) => {
  set(countAtom, 0);
});
typescript// Counter.tsx
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import {
  countAtom,
  stepAtom,
  incrementAtom,
  decrementAtom,
  resetAtom,
} from './atoms/counterAtoms';

const Counter = () => {
  const count = useAtomValue(countAtom);
  const [step, setStep] = useAtom(stepAtom);
  const increment = useSetAtom(incrementAtom);
  const decrement = useSetAtom(decrementAtom);
  const reset = useSetAtom(resetAtom);

  return (
    <div>
      <h2>カウント: {count}</h2>
      <input
        type='number'
        value={step}
        onChange={(e) => setStep(Number(e.target.value))}
      />
      <button onClick={increment}>+{step}</button>
      <button onClick={decrement}>-{step}</button>
      <button onClick={reset}>リセット</button>
    </div>
  );
};

この実装の利点:

  • ビジネスロジックの分離: カウンターのロジックがコンポーネントから独立
  • 再利用性: 他のコンポーネントから同じアクションを実行可能
  • テスト性: ビジネスロジックを独立してテスト可能

Todo アプリケーションでの応用

より実用的な例として、Todo アプリケーションでアクション atom を活用してみましょう。

型定義と基本的な atom

typescript// types/todo.ts
export interface Todo {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
}

export interface TodoFilter {
  status: 'all' | 'active' | 'completed';
  search: string;
}
typescript// atoms/todoAtoms.ts
import { atom } from 'jotai';
import { Todo, TodoFilter } from '../types/todo';

// 状態管理用のatom
export const todosAtom = atom<Todo[]>([]);
export const filterAtom = atom<TodoFilter>({
  status: 'all',
  search: '',
});
export const loadingAtom = atom(false);
export const errorAtom = atom<string | null>(null);

// 派生状態(読み取り専用)
export const filteredTodosAtom = atom((get) => {
  const todos = get(todosAtom);
  const filter = get(filterAtom);

  return todos.filter((todo) => {
    const matchesStatus =
      filter.status === 'all' ||
      (filter.status === 'active' && !todo.completed) ||
      (filter.status === 'completed' && todo.completed);

    const matchesSearch =
      filter.search === '' ||
      todo.title
        .toLowerCase()
        .includes(filter.search.toLowerCase());

    return matchesStatus && matchesSearch;
  });
});

アクション atoms の実装

typescript// atoms/todoAtoms.ts(続き)

// Todo作成アクション
export const createTodoAtom = atom(
  null,
  async (get, set, title: string) => {
    if (!title.trim()) {
      set(errorAtom, 'タイトルを入力してください');
      return;
    }

    set(loadingAtom, true);
    set(errorAtom, null);

    try {
      const newTodo: Todo = {
        id: crypto.randomUUID(),
        title: title.trim(),
        completed: false,
        createdAt: new Date(),
      };

      // API呼び出し(実際の実装では)
      const response = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newTodo),
      });

      if (!response.ok) {
        throw new Error('Todo作成に失敗しました');
      }

      const currentTodos = get(todosAtom);
      set(todosAtom, [newTodo, ...currentTodos]);
    } catch (error) {
      set(errorAtom, error.message);
    } finally {
      set(loadingAtom, false);
    }
  }
);

// Todo更新アクション
export const updateTodoAtom = atom(
  null,
  async (
    get,
    set,
    { id, updates }: { id: string; updates: Partial<Todo> }
  ) => {
    set(loadingAtom, true);
    set(errorAtom, null);

    try {
      const response = await fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates),
      });

      if (!response.ok) {
        throw new Error('Todo更新に失敗しました');
      }

      const currentTodos = get(todosAtom);
      const updatedTodos = currentTodos.map((todo) =>
        todo.id === id ? { ...todo, ...updates } : todo
      );
      set(todosAtom, updatedTodos);
    } catch (error) {
      set(errorAtom, error.message);
    } finally {
      set(loadingAtom, false);
    }
  }
);

コンポーネントでの使用

typescript// components/TodoApp.tsx
import { useAtomValue, useSetAtom } from 'jotai';
import {
  filteredTodosAtom,
  createTodoAtom,
  updateTodoAtom,
  loadingAtom,
  errorAtom,
} from '../atoms/todoAtoms';

const TodoApp = () => {
  const todos = useAtomValue(filteredTodosAtom);
  const loading = useAtomValue(loadingAtom);
  const error = useAtomValue(errorAtom);
  const createTodo = useSetAtom(createTodoAtom);
  const updateTodo = useSetAtom(updateTodoAtom);

  const handleAddTodo = (title: string) => {
    createTodo(title);
  };

  const handleToggleTodo = (
    id: string,
    completed: boolean
  ) => {
    updateTodo({ id, updates: { completed } });
  };

  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;

  return (
    <div>
      <TodoForm onSubmit={handleAddTodo} />
      <TodoList todos={todos} onToggle={handleToggleTodo} />
    </div>
  );
};

非同期処理を含む複雑なケース

実際のアプリケーションでは、より複雑な非同期処理が必要になることがあります。以下は、ユーザー認証とデータ同期を含む例です。

複雑な状態管理

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

interface User {
  id: string;
  email: string;
  name: string;
  avatar?: string;
}

interface AuthState {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
}

// 認証状態管理
export const authStateAtom = atom<AuthState>({
  user: null,
  token: null,
  isAuthenticated: false,
});

export const authLoadingAtom = atom(false);
export const authErrorAtom = atom<string | null>(null);

// 複雑なログインアクション
export const loginAtom = atom(
  null,
  async (
    get,
    set,
    credentials: { email: string; password: string }
  ) => {
    set(authLoadingAtom, true);
    set(authErrorAtom, null);

    try {
      // バリデーション
      if (!credentials.email || !credentials.password) {
        throw new Error(
          'メールアドレスとパスワードを入力してください'
        );
      }

      // API呼び出し
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials),
      });

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(
          errorData.message || 'ログインに失敗しました'
        );
      }

      const { user, token } = await response.json();

      // 成功時の状態更新
      set(authStateAtom, {
        user,
        token,
        isAuthenticated: true,
      });

      // ローカルストレージにトークンを保存
      localStorage.setItem('authToken', token);

      // 他のatom の状態も更新(例:ユーザー設定を取得)
      const userPrefsResponse = await fetch(
        '/api/user/preferences',
        {
          headers: { Authorization: `Bearer ${token}` },
        }
      );

      if (userPrefsResponse.ok) {
        const preferences = await userPrefsResponse.json();
        // 設定atomを更新...
      }
    } catch (error) {
      // エラーハンドリング
      console.error('Login error:', error);
      set(authErrorAtom, error.message);

      // 特定のエラーコードに応じた処理
      if (error.message.includes('Invalid credentials')) {
        // 認証情報が無効な場合の処理
        set(
          authErrorAtom,
          'メールアドレスまたはパスワードが正しくありません'
        );
      } else if (
        error.message.includes('Too many attempts')
      ) {
        // レート制限に引っかかった場合
        set(
          authErrorAtom,
          'ログイン試行回数が上限に達しました。しばらくお待ちください'
        );
      }
    } finally {
      set(authLoadingAtom, false);
    }
  }
);

エラーハンドリングとリトライ機能

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

// リトライ機能付きのAPI呼び出しアクション
export const fetchWithRetryAtom = atom(
  null,
  async (
    get,
    set,
    { url, options = {}, maxRetries = 3, retryDelay = 1000 }
  ) => {
    set(loadingAtom, true);
    set(errorAtom, null);

    let lastError: Error | null = null;

    for (
      let attempt = 0;
      attempt <= maxRetries;
      attempt++
    ) {
      try {
        const response = await fetch(url, options);

        if (!response.ok) {
          throw new Error(
            `HTTP ${response.status}: ${response.statusText}`
          );
        }

        const data = await response.json();
        set(loadingAtom, false);
        return data;
      } catch (error) {
        lastError = error;
        console.warn(
          `Attempt ${attempt + 1} failed:`,
          error.message
        );

        // 最後の試行でない場合は待機
        if (attempt < maxRetries) {
          await new Promise((resolve) =>
            setTimeout(resolve, retryDelay)
          );
        }
      }
    }

    // 全ての試行が失敗した場合
    set(
      errorAtom,
      `${maxRetries + 1}回の試行後に失敗: ${
        lastError?.message
      }`
    );
    set(loadingAtom, false);
    throw lastError;
  }
);

これらの例では、以下の実際のエラーコードが含まれています:

javascriptHTTP 401: Unauthorized
HTTP 429: Too Many Requests
HTTP 500: Internal Server Error
Error: Cannot read properties of undefined (reading 'map')
TypeError: Failed to fetch

このようなエラーハンドリングにより、ユーザーにとって理解しやすいエラーメッセージを提供できます。

まとめ

アクション atom 採用のメリット

アクション atom を採用することで、あなたの React アプリケーションは驚くほど整理され、保守性が向上します。実際に導入した開発者の多くが、以下のようなメリットを実感しています。

1. コードの可読性向上

従来のコンポーネントベースの状態管理では、ビジネスロジックと UI ロジックが混在していました。アクション atom を使うことで、「何をする」(アクション)と「どう見せる」(コンポーネント)が明確に分離されます。

typescript// Before: 混在したロジック
const Component = () => {
  const [data, setData] = useState([]);
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (formData) => {
    setLoading(true);
    try {
      const response = await fetch('/api/data', {
        method: 'POST',
        body: JSON.stringify(formData)
      });
      const result = await response.json();
      setData(result);
    } catch (error) {
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  return (/* JSX */);
};

// After: 分離されたロジック
const Component = () => {
  const data = useAtomValue(dataAtom);
  const loading = useAtomValue(loadingAtom);
  const submitData = useSetAtom(submitDataAtom);

  return (/* JSX */);
};

2. テストの容易さ

ビジネスロジックがコンポーネントから分離されることで、テストが驚くほど簡単になります。

typescript// アクション atom のテスト例
describe('createTodoAtom', () => {
  it('should create a new todo', async () => {
    const store = createStore();

    // アクションの実行
    await store.set(createTodoAtom, 'New Todo');

    // 結果の検証
    const todos = store.get(todosAtom);
    expect(todos).toHaveLength(1);
    expect(todos[0].title).toBe('New Todo');
  });

  it('should handle errors gracefully', async () => {
    const store = createStore();

    // エラーケースのテスト
    await store.set(createTodoAtom, '');

    const error = store.get(errorAtom);
    expect(error).toBe('タイトルを入力してください');
  });
});

3. 再利用性の向上

一度作成したアクション atom は、複数のコンポーネントで再利用できます。これにより、コードの重複を削減し、一貫性を保つことができます。

従来の課題アクション atom による解決
同じロジックを複数の場所で実装一つのアクション atom で統一
API 変更時の影響範囲が広いアクション atom の修正のみで対応
エラーハンドリングが不統一統一されたエラーハンドリング

適用場面と注意点

アクション atom は非常に強力な概念ですが、適切に使用することが重要です。

適用に適したケース

  1. 複雑な状態更新: 複数の atom を同時に更新する必要がある場合
  2. 非同期処理: API 呼び出しや非同期処理を含む操作
  3. ビジネスロジック: データの変換や計算を含む処理
  4. 副作用の管理: ローカルストレージの更新、外部 API の呼び出し
typescript// 適用例:複雑な状態更新
const checkoutAtom = atom(
  null,
  async (get, set, orderData) => {
    set(loadingAtom, true);

    try {
      // 1. 在庫の確認
      const inventory = get(inventoryAtom);
      const isAvailable = checkInventory(
        inventory,
        orderData.items
      );

      if (!isAvailable) {
        throw new Error('在庫が不足しています');
      }

      // 2. 注文処理
      const order = await processOrder(orderData);

      // 3. 複数状態の更新
      set(orderAtom, order);
      set(
        inventoryAtom,
        updateInventory(inventory, orderData.items)
      );
      set(cartAtom, []);

      // 4. 通知の送信
      await sendOrderConfirmation(order);
    } catch (error) {
      set(errorAtom, error.message);
    } finally {
      set(loadingAtom, false);
    }
  }
);

注意が必要なケース

  1. シンプルな状態更新: 単純な値の変更には過度に複雑
  2. 頻繁な更新: 高頻度で呼び出される処理には向かない
  3. 同期処理: 計算のみの単純な変換処理
typescript// 不適切な使用例
const simpleIncrementAtom = atom(null, (get, set) => {
  const current = get(countAtom);
  set(countAtom, current + 1); // これは過度に複雑
});

// より適切な方法
const countAtom = atom(0);
// コンポーネントで直接: setCount(prev => prev + 1)

パフォーマンス考慮事項

アクション atom を使用する際は、以下の点に注意しましょう:

  1. 無限ループの回避: アクション atom 内で他のアクション atom を呼び出す際は注意
  2. メモリリークの防止: 非同期処理のクリーンアップを適切に実装
  3. バッチ更新: 複数の状態更新を適切にバッチ処理
typescript// 良い例:バッチ更新
const batchUpdateAtom = atom(null, (get, set, updates) => {
  // 複数の更新をまとめて実行
  Object.entries(updates).forEach(([key, value]) => {
    set(atomMap[key], value);
  });
});

今後の展望

アクション atom は、React の状態管理において新しい可能性を開きます。この概念を理解し活用することで、より保守性の高い、テストしやすいアプリケーションを構築できるようになります。

特に、関数型プログラミングの思想と相性が良く、不変性を保ちながら複雑な状態管理を実現できる点が魅力です。

typescript// 関数型の思想を活かした実装例
const functionalUpdateAtom = atom(
  null,
  (get, set, updateFn) => {
    const currentState = get(appStateAtom);
    const newState = updateFn(currentState); // 不変な更新
    set(appStateAtom, newState);
  }
);

あなたのプロジェクトでも、小さなアクション atom から始めて、徐々に適用範囲を広げていくことをお勧めします。きっと、コードの品質向上を実感できるはずです。

関連リンク

公式ドキュメント

参考記事とリソース

実践的なサンプルコード

開発ツールとエコシステム

  • Jotai DevTools - 開発時のデバッグツール
  • Jotai Optics - 複雑な状態操作のためのライブラリ
  • Jotai Query - データフェッチングのためのエクステンション

コミュニティとサポート