T-CREATOR

Jotai ビジネスロジックを UI から分離する「アクション atom」という考え方

Jotai ビジネスロジックを UI から分離する「アクション atom」という考え方

React 開発において、コンポーネントが肥大化し、ビジネスロジックと UI 表示ロジックが絡み合ってしまう問題に直面したことはありませんか?

従来の状態管理では、どうしてもコンポーネント内にビジネスルールや複雑な処理が混在してしまい、テストやメンテナンスが困難になってしまいます。しかし、Jotai の「アクション atom」という考え方を活用することで、この長年の課題を根本的に解決できるのです。

今回は、ビジネスロジックと UI を完全に分離し、保守性とテスタビリティを劇的に向上させるアクション atom の実装方法について、実践的な例を交えながら詳しく解説していきます。

なぜビジネスロジックと UI の分離が重要なのか

分離がもたらす 3 つの大きなメリット

ビジネスロジックと UI の分離は、単なる設計の美しさを求めるものではありません。実際の開発現場で直面する具体的な問題を解決する、非常に実用的なアプローチなのです。

1. テストの簡素化と品質向上

ビジネスロジックが分離されていると、UI コンポーネントを描画することなく、純粋な関数として単体テストを実行できます。これにより、テストの実行速度が格段に向上し、より多くのテストケースを効率的に実装できるようになります。

2. 再利用性の飛躍的向上

同じビジネスロジックを異なる UI コンポーネントで使い回せるため、開発効率が大幅に向上します。モバイル版とデスクトップ版で異なる UI を提供する場合でも、ビジネスロジックは共通化できるのです。

3. 保守性とメンテナンス性の確保

ビジネスルールの変更が発生した際、UI コンポーネントを触ることなく、ロジック部分のみを修正できます。これにより、意図しない副作用を防ぎながら、安全にアプリケーションを進化させていけるでしょう。

現代の Web アプリケーション開発における必然性

現代の Web アプリケーションは、ますます複雑になっています。ユーザーの期待値も高く、リッチな機能と直感的な UI の両立が求められているのです。

このような環境では、技術的負債を溜め込まずに継続的に機能を追加していくために、適切な設計パターンの採用が不可欠となります。ビジネスロジックと UI の分離は、まさにこの要求に応える重要な設計原則なのです。

従来の React 開発で起こる「密結合の罠」

よくある問題パターン:コンポーネントの肥大化

多くの React 開発者が経験する典型的な問題を見てみましょう。以下は、ユーザー管理機能を持つコンポーネントの例です。

typescript// 問題のあるコンポーネント例
const UserManagement: React.FC = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [searchTerm, setSearchTerm] = useState('');
  const [sortBy, setSortBy] = useState<
    'name' | 'email' | 'createdAt'
  >('name');
  const [filterBy, setFilterBy] = useState<
    'active' | 'inactive' | 'all'
  >('all');

  // ビジネスロジックがコンポーネント内に混在
  const fetchUsers = async () => {
    setLoading(true);
    setError(null);
    try {
      const response = await api.get('/users');
      const userData = response.data;

      // データ変換ロジック
      const processedUsers = userData.map((user) => ({
        ...user,
        displayName: `${user.firstName} ${user.lastName}`,
        isActive:
          user.status === 'active' &&
          user.lastLoginAt >
            Date.now() - 30 * 24 * 60 * 60 * 1000,
      }));

      setUsers(processedUsers);
    } catch (err) {
      setError('ユーザー情報の取得に失敗しました');
    } finally {
      setLoading(false);
    }
  };

  const deleteUser = async (userId: string) => {
    if (!confirm('本当に削除しますか?')) return;

    try {
      await api.delete(`/users/${userId}`);
      setUsers((prev) =>
        prev.filter((user) => user.id !== userId)
      );
      // 成功通知の表示ロジック
      showNotification('ユーザーを削除しました', 'success');
    } catch (err) {
      showNotification('削除に失敗しました', 'error');
    }
  };

  // フィルタリングロジック
  const filteredUsers = useMemo(() => {
    return users
      .filter((user) => {
        if (filterBy === 'active') return user.isActive;
        if (filterBy === 'inactive') return !user.isActive;
        return true;
      })
      .filter(
        (user) =>
          user.displayName
            .toLowerCase()
            .includes(searchTerm.toLowerCase()) ||
          user.email
            .toLowerCase()
            .includes(searchTerm.toLowerCase())
      )
      .sort((a, b) => {
        if (sortBy === 'name')
          return a.displayName.localeCompare(b.displayName);
        if (sortBy === 'email')
          return a.email.localeCompare(b.email);
        return (
          new Date(b.createdAt).getTime() -
          new Date(a.createdAt).getTime()
        );
      });
  }, [users, searchTerm, sortBy, filterBy]);

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

  // 長大なJSXが続く...
  return <div>{/* 複雑なUI表示ロジック */}</div>;
};

この設計が引き起こす具体的な問題

1. テストの困難さ

上記のコンポーネントをテストする場合、UI 描画、API 通信、データ変換、フィルタリングロジックがすべて一つのコンポーネントに含まれているため、個別の機能をテストすることが困難です。

typescript// テストが困難な例
describe('UserManagement', () => {
  it('should filter users correctly', () => {
    // UIコンポーネント全体をマウントする必要がある
    // API呼び出しをモックする必要がある
    // 複雑なセットアップが必要
  });
});

2. 責任の境界が曖昧

一つのコンポーネントが以下のような複数の責任を持ってしまっています:

  • データの取得と管理
  • データの変換と加工
  • フィルタリングとソート
  • UI 表示
  • エラーハンドリング
  • ユーザーインタラクション

3. 再利用性の欠如

このユーザー管理のロジックを別のページで使いたい場合、UI と密結合しているため、ロジック部分だけを抽出することができません。

カスタムフックによる改善の限界

多くの開発者が、この問題をカスタムフックで解決しようとします。確かに一定の改善は見込めますが、根本的な解決には至りません。

typescript// カスタムフックによる改善例
const useUserManagement = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(false);
  // ... 他のstate

  const fetchUsers = async () => {
    // ロジックの実装
  };

  return {
    users,
    loading,
    fetchUsers,
    deleteUser,
    filteredUsers,
  };
};

この方法でも、以下の課題が残ります:

  • 状態の分散: 関連する状態が複数のフックに分散してしまう可能性
  • 依存関係の複雑化: フック間の依存関係が複雑になりがち
  • グローバル状態との連携: 他のコンポーネントとの状態共有が困難

アクション atom という革新的なアプローチ

アクション atom の基本概念

Jotai のアクション atom は、従来の state 管理とは根本的に異なるアプローチを提供します。単なる値の保持ではなく、「行動」や「操作」そのものを atom として定義することで、ビジネスロジックを完全に分離できるのです。

typescript// アクション atomの基本形
import { atom } from 'jotai';

// 書き込み専用のアクション atom
const incrementCounterAction = atom(
  null, // 読み込み値は null(書き込み専用)
  (get, set, by: number) => {
    const currentCount = get(counterAtom);
    set(counterAtom, currentCount + by);
  }
);

従来のアプローチとの決定的な違い

従来のアプローチ:状態中心の設計

typescript// 従来:状態を中心とした設計
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(false);

// コンポーネント内でビジネスロジックを実装
const handleIncrement = () => {
  setLoading(true);
  // 複雑なビジネスロジック
  setTimeout(() => {
    setCount((prev) => prev + 1);
    setLoading(false);
  }, 1000);
};

アクション atom:行動中心の設計

typescript// アクション atom:行動を中心とした設計
const counterAtom = atom(0);
const loadingAtom = atom(false);

// ビジネスロジックをatomとして分離
const incrementWithDelayAction = atom(
  null,
  async (get, set, amount: number) => {
    set(loadingAtom, true);

    // ビジネスルール:1秒待ってからインクリメント
    await new Promise((resolve) =>
      setTimeout(resolve, 1000)
    );

    const current = get(counterAtom);
    set(counterAtom, current + amount);
    set(loadingAtom, false);
  }
);

アクション atom の 3 つの強力な特徴

1. 純粋関数としての実装

アクション atom は純粋関数として実装されるため、副作用が明確に分離され、テストが容易になります。

typescript// 純粋関数として実装されたアクション
const calculateTotalPriceAction = atom(
  null,
  (get, set, items: CartItem[]) => {
    // 税率計算のビジネスルール
    const TAX_RATE = 0.1;
    const SHIPPING_THRESHOLD = 5000;

    const subtotal = items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
    const tax = subtotal * TAX_RATE;
    const shipping =
      subtotal >= SHIPPING_THRESHOLD ? 0 : 500;
    const total = subtotal + tax + shipping;

    set(cartTotalAtom, {
      subtotal,
      tax,
      shipping,
      total,
    });
  }
);

2. 組み合わせ可能な設計

複数のアクション atom を組み合わせることで、より複雑なビジネスフローを構築できます。

typescript// 基本的なアクション atom
const addToCartAction = atom(
  null,
  (get, set, product: Product) => {
    const currentCart = get(cartItemsAtom);
    const existingItem = currentCart.find(
      (item) => item.id === product.id
    );

    if (existingItem) {
      set(
        cartItemsAtom,
        currentCart.map((item) =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        )
      );
    } else {
      set(cartItemsAtom, [
        ...currentCart,
        { ...product, quantity: 1 },
      ]);
    }
  }
);

// 複合的なアクション atom
const addToCartAndCalculateAction = atom(
  null,
  (get, set, product: Product) => {
    // 基本アクションを実行
    set(addToCartAction, product);

    // 計算アクションを実行
    const updatedItems = get(cartItemsAtom);
    set(calculateTotalPriceAction, updatedItems);

    // 通知アクションを実行
    set(showNotificationAction, {
      message: `${product.name}をカートに追加しました`,
      type: 'success',
    });
  }
);

3. 型安全性の確保

TypeScript と組み合わせることで、アクションの引数や戻り値の型安全性を確保できます。

typescript// 型安全なアクション atom
interface UpdateUserParams {
  userId: string;
  updates: Partial<User>;
}

interface UpdateUserResult {
  success: boolean;
  user?: User;
  error?: string;
}

const updateUserAction = atom(
  null,
  async (
    get,
    set,
    params: UpdateUserParams
  ): Promise<UpdateUserResult> => {
    try {
      set(loadingAtom, true);

      const response = await userApi.update(
        params.userId,
        params.updates
      );
      const updatedUser = response.data;

      // ユーザーリストを更新
      const currentUsers = get(usersAtom);
      set(
        usersAtom,
        currentUsers.map((user) =>
          user.id === params.userId ? updatedUser : user
        )
      );

      return { success: true, user: updatedUser };
    } catch (error) {
      const errorMessage =
        error instanceof Error
          ? error.message
          : 'Unknown error';
      return { success: false, error: errorMessage };
    } finally {
      set(loadingAtom, false);
    }
  }
);

ビジネスロジック分離の具体的実装パターン

パターン 1:CRUD 操作の完全分離

データベースとの CRUD 操作を行うビジネスロジックを、UI から完全に分離する実装パターンです。

typescript// データ管理用のatom
const usersAtom = atom<User[]>([]);
const loadingAtom = atom(false);
const errorAtom = atom<string | null>(null);

// Create: ユーザー作成アクション
const createUserAction = atom(
  null,
  async (get, set, userData: CreateUserData) => {
    set(loadingAtom, true);
    set(errorAtom, null);

    try {
      // バリデーションロジック
      if (!userData.email || !userData.name) {
        throw new Error('必須項目が入力されていません');
      }

      if (!isValidEmail(userData.email)) {
        throw new Error(
          'メールアドレスの形式が正しくありません'
        );
      }

      // API呼び出し
      const response = await api.post('/users', userData);
      const newUser = response.data;

      // 状態更新
      const currentUsers = get(usersAtom);
      set(usersAtom, [...currentUsers, newUser]);

      // 成功通知
      set(notificationAction, {
        message: 'ユーザーを作成しました',
        type: 'success',
      });

      return { success: true, user: newUser };
    } catch (error) {
      const errorMessage =
        error instanceof Error
          ? error.message
          : 'ユーザー作成に失敗しました';
      set(errorAtom, errorMessage);
      return { success: false, error: errorMessage };
    } finally {
      set(loadingAtom, false);
    }
  }
);

// Read: ユーザー一覧取得アクション
const fetchUsersAction = atom(
  null,
  async (get, set, filters?: UserFilters) => {
    set(loadingAtom, true);
    set(errorAtom, null);

    try {
      const queryParams = filters
        ? buildQueryParams(filters)
        : '';
      const response = await api.get(
        `/users${queryParams}`
      );

      // データ変換ロジック
      const users = response.data.map((user: any) => ({
        ...user,
        fullName: `${user.firstName} ${user.lastName}`,
        isActive: user.status === 'active',
        formattedCreatedAt: formatDate(user.createdAt),
      }));

      set(usersAtom, users);
      return { success: true, users };
    } catch (error) {
      const errorMessage =
        'ユーザー情報の取得に失敗しました';
      set(errorAtom, errorMessage);
      return { success: false, error: errorMessage };
    } finally {
      set(loadingAtom, false);
    }
  }
);

パターン 2:複雑なフォーム処理の分離

フォームのバリデーション、送信、エラーハンドリングを含む複雑な処理を分離するパターンです。

typescript// フォーム状態管理
const formDataAtom = atom<ContactFormData>({
  name: '',
  email: '',
  message: '',
  category: 'general',
});

const formErrorsAtom = atom<Record<string, string>>({});
const isSubmittingAtom = atom(false);

// バリデーションロジックの分離
const validateFormAction = atom(
  null,
  (get, set, data: ContactFormData) => {
    const errors: Record<string, string> = {};

    // 名前バリデーション
    if (!data.name.trim()) {
      errors.name = '名前は必須です';
    } else if (data.name.length < 2) {
      errors.name = '名前は2文字以上で入力してください';
    }

    // メールバリデーション
    if (!data.email.trim()) {
      errors.email = 'メールアドレスは必須です';
    } else if (!isValidEmail(data.email)) {
      errors.email =
        '正しいメールアドレスを入力してください';
    }

    // メッセージバリデーション
    if (!data.message.trim()) {
      errors.message = 'メッセージは必須です';
    } else if (data.message.length < 10) {
      errors.message =
        'メッセージは10文字以上で入力してください';
    }

    set(formErrorsAtom, errors);
    return Object.keys(errors).length === 0;
  }
);

// フォーム送信ロジックの分離
const submitContactFormAction = atom(
  null,
  async (get, set) => {
    const formData = get(formDataAtom);

    // バリデーション実行
    const isValid = get(validateFormAction, formData);
    if (!isValid) {
      return {
        success: false,
        error: 'バリデーションエラーがあります',
      };
    }

    set(isSubmittingAtom, true);

    try {
      // スパム検知ロジック
      if (await isSpamContent(formData.message)) {
        throw new Error('不適切な内容が含まれています');
      }

      // API送信
      const response = await api.post('/contact', {
        ...formData,
        timestamp: new Date().toISOString(),
        userAgent: navigator.userAgent,
      });

      // 成功時の処理
      set(formDataAtom, {
        name: '',
        email: '',
        message: '',
        category: 'general',
      });

      set(notificationAction, {
        message: 'お問い合わせを送信しました',
        type: 'success',
      });

      return { success: true, data: response.data };
    } catch (error) {
      const errorMessage =
        error instanceof Error
          ? error.message
          : '送信に失敗しました';
      return { success: false, error: errorMessage };
    } finally {
      set(isSubmittingAtom, false);
    }
  }
);

パターン 3:非同期処理とキャッシュ管理

API 呼び出しの結果をキャッシュし、効率的なデータ管理を行うパターンです。

typescript// キャッシュ管理用atom
const userCacheAtom = atom<Map<string, User>>(new Map());
const cacheTimestampsAtom = atom<Map<string, number>>(
  new Map()
);

// キャッシュ有効性チェック
const isCacheValidAtom = atom(
  (get) =>
    (userId: string, maxAge: number = 5 * 60 * 1000) => {
      const timestamps = get(cacheTimestampsAtom);
      const timestamp = timestamps.get(userId);

      if (!timestamp) return false;
      return Date.now() - timestamp < maxAge;
    }
);

// ユーザー情報取得(キャッシュ対応)
const fetchUserWithCacheAction = atom(
  null,
  async (
    get,
    set,
    userId: string,
    forceRefresh: boolean = false
  ) => {
    // キャッシュチェック
    if (!forceRefresh && get(isCacheValidAtom)(userId)) {
      const cache = get(userCacheAtom);
      const cachedUser = cache.get(userId);
      if (cachedUser) {
        return {
          success: true,
          user: cachedUser,
          fromCache: true,
        };
      }
    }

    set(loadingAtom, true);

    try {
      const response = await api.get(`/users/${userId}`);
      const user = response.data;

      // キャッシュ更新
      const currentCache = get(userCacheAtom);
      const currentTimestamps = get(cacheTimestampsAtom);

      set(
        userCacheAtom,
        new Map(currentCache).set(userId, user)
      );
      set(
        cacheTimestampsAtom,
        new Map(currentTimestamps).set(userId, Date.now())
      );

      return { success: true, user, fromCache: false };
    } catch (error) {
      return {
        success: false,
        error:
          error instanceof Error
            ? error.message
            : 'ユーザー情報の取得に失敗しました',
      };
    } finally {
      set(loadingAtom, false);
    }
  }
);

// キャッシュクリア機能
const clearUserCacheAction = atom(
  null,
  (get, set, userId?: string) => {
    if (userId) {
      // 特定ユーザーのキャッシュをクリア
      const cache = get(userCacheAtom);
      const timestamps = get(cacheTimestampsAtom);

      const newCache = new Map(cache);
      const newTimestamps = new Map(timestamps);

      newCache.delete(userId);
      newTimestamps.delete(userId);

      set(userCacheAtom, newCache);
      set(cacheTimestampsAtom, newTimestamps);
    } else {
      // 全キャッシュをクリア
      set(userCacheAtom, new Map());
      set(cacheTimestampsAtom, new Map());
    }
  }
);

これらの実装パターンにより、ビジネスロジックが UI から完全に分離され、テストしやすく、再利用可能な設計が実現できます。次のセクションでは、これらのパターンを実際のプロジェクトでどのように活用するかを詳しく見ていきましょう。

実際のプロジェクトでの活用事例

事例 1:EC サイトのショッピングカート機能

実際の EC サイトで使用されているショッピングカート機能を、アクション atom で実装した事例をご紹介します。

従来の実装での課題

typescript// 問題のある従来の実装
const ShoppingCart: React.FC = () => {
  const [items, setItems] = useState<CartItem[]>([]);
  const [total, setTotal] = useState(0);
  const [discount, setDiscount] = useState(0);
  const [shipping, setShipping] = useState(0);
  const [loading, setLoading] = useState(false);

  // 複雑なビジネスロジックがコンポーネント内に散在
  const addItem = (product: Product) => {
    setItems((prev) => {
      const existing = prev.find(
        (item) => item.id === product.id
      );
      if (existing) {
        return prev.map((item) =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      }
      return [...prev, { ...product, quantity: 1 }];
    });

    // 価格計算ロジックが複雑
    calculateTotals();
  };

  const calculateTotals = () => {
    // 複雑な計算ロジック...
  };

  // JSXが長大になる...
};

アクション atom による改善実装

typescript// 状態管理atom
const cartItemsAtom = atom<CartItem[]>([]);
const cartMetaAtom = atom({
  subtotal: 0,
  discount: 0,
  shipping: 0,
  tax: 0,
  total: 0,
});

// ビジネスルール定義
const SHIPPING_RULES = {
  FREE_SHIPPING_THRESHOLD: 5000,
  STANDARD_SHIPPING_COST: 500,
  EXPRESS_SHIPPING_COST: 1000,
} as const;

const TAX_RATE = 0.1;

// 商品追加アクション
const addToCartAction = atom(
  null,
  (get, set, product: Product, quantity: number = 1) => {
    const currentItems = get(cartItemsAtom);
    const existingItemIndex = currentItems.findIndex(
      (item) => item.id === product.id
    );

    let updatedItems: CartItem[];

    if (existingItemIndex >= 0) {
      // 既存商品の数量更新
      updatedItems = currentItems.map((item, index) =>
        index === existingItemIndex
          ? { ...item, quantity: item.quantity + quantity }
          : item
      );
    } else {
      // 新規商品追加
      updatedItems = [
        ...currentItems,
        { ...product, quantity },
      ];
    }

    set(cartItemsAtom, updatedItems);

    // 自動的に合計金額を再計算
    set(calculateCartTotalsAction);

    // 在庫チェック
    set(validateStockAction, product.id);
  }
);

// 価格計算アクション(複雑なビジネスルールを集約)
const calculateCartTotalsAction = atom(null, (get, set) => {
  const items = get(cartItemsAtom);
  const userTier = get(userTierAtom);
  const appliedCoupons = get(appliedCouponsAtom);

  // 小計計算
  const subtotal = items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  // 割引計算(会員ランクやクーポンを考慮)
  let discount = 0;

  // 会員ランク割引
  if (userTier === 'premium') {
    discount += subtotal * 0.05; // 5%割引
  } else if (userTier === 'gold') {
    discount += subtotal * 0.03; // 3%割引
  }

  // クーポン割引
  appliedCoupons.forEach((coupon) => {
    if (coupon.type === 'percentage') {
      discount += subtotal * (coupon.value / 100);
    } else if (coupon.type === 'fixed') {
      discount += coupon.value;
    }
  });

  // 送料計算
  const discountedSubtotal = subtotal - discount;
  const shipping =
    discountedSubtotal >=
    SHIPPING_RULES.FREE_SHIPPING_THRESHOLD
      ? 0
      : SHIPPING_RULES.STANDARD_SHIPPING_COST;

  // 税金計算
  const tax = (discountedSubtotal + shipping) * TAX_RATE;

  // 合計金額
  const total = discountedSubtotal + shipping + tax;

  set(cartMetaAtom, {
    subtotal,
    discount,
    shipping,
    tax,
    total,
  });
});

// 在庫確認アクション
const validateStockAction = atom(
  null,
  async (get, set, productId: string) => {
    try {
      const response = await api.get(
        `/products/${productId}/stock`
      );
      const { available, reserved } = response.data;

      const cartItems = get(cartItemsAtom);
      const cartItem = cartItems.find(
        (item) => item.id === productId
      );

      if (
        cartItem &&
        cartItem.quantity > available - reserved
      ) {
        // 在庫不足の場合は数量を調整
        const maxQuantity = Math.max(
          0,
          available - reserved
        );

        set(
          cartItemsAtom,
          cartItems.map((item) =>
            item.id === productId
              ? { ...item, quantity: maxQuantity }
              : item
          )
        );

        set(showNotificationAction, {
          message: `${cartItem.name}の在庫が不足しています。数量を${maxQuantity}個に調整しました。`,
          type: 'warning',
        });

        // 合計金額を再計算
        set(calculateCartTotalsAction);
      }
    } catch (error) {
      console.error('在庫確認エラー:', error);
    }
  }
);

// 注文確定アクション
const checkoutAction = atom(
  null,
  async (get, set, paymentInfo: PaymentInfo) => {
    const items = get(cartItemsAtom);
    const totals = get(cartMetaAtom);

    if (items.length === 0) {
      return { success: false, error: 'カートが空です' };
    }

    set(loadingAtom, true);

    try {
      // 最終在庫確認
      for (const item of items) {
        await set(validateStockAction, item.id);
      }

      // 注文データ作成
      const orderData = {
        items: get(cartItemsAtom), // 在庫確認後の最新データ
        totals: get(cartMetaAtom),
        paymentInfo,
        timestamp: new Date().toISOString(),
      };

      // 注文処理
      const response = await api.post('/orders', orderData);
      const order = response.data;

      // カート初期化
      set(cartItemsAtom, []);
      set(cartMetaAtom, {
        subtotal: 0,
        discount: 0,
        shipping: 0,
        tax: 0,
        total: 0,
      });

      // 成功通知
      set(showNotificationAction, {
        message: 'ご注文ありがとうございました!',
        type: 'success',
      });

      return { success: true, order };
    } catch (error) {
      const errorMessage =
        error instanceof Error
          ? error.message
          : '注文処理に失敗しました';
      return { success: false, error: errorMessage };
    } finally {
      set(loadingAtom, false);
    }
  }
);

UI コンポーネントの簡素化

アクション atom を使用することで、UI コンポーネントは表示に専念できます。

typescriptconst ShoppingCart: React.FC = () => {
  const [items] = useAtom(cartItemsAtom);
  const [totals] = useAtom(cartMetaAtom);
  const [loading] = useAtom(loadingAtom);
  const [, addToCart] = useAtom(addToCartAction);
  const [, checkout] = useAtom(checkoutAction);

  // UIコンポーネントはシンプルに
  return (
    <div className='shopping-cart'>
      <h2>ショッピングカート</h2>

      {items.map((item) => (
        <CartItem
          key={item.id}
          item={item}
          onAdd={() => addToCart(item, 1)}
        />
      ))}

      <CartSummary totals={totals} />

      <CheckoutButton
        onCheckout={checkout}
        disabled={loading || items.length === 0}
      />
    </div>
  );
};

事例 2:リアルタイム通知システム

WebSocket を使用したリアルタイム通知システムの実装事例です。

typescript// 通知関連のatom
const notificationsAtom = atom<Notification[]>([]);
const unreadCountAtom = atom(
  (get) =>
    get(notificationsAtom).filter((n) => !n.isRead).length
);

// WebSocket接続管理
const websocketConnectionAtom = atom<WebSocket | null>(
  null
);

// WebSocket接続アクション
const connectWebSocketAction = atom(
  null,
  (get, set, userId: string) => {
    const existingConnection = get(websocketConnectionAtom);

    // 既存の接続があれば閉じる
    if (existingConnection) {
      existingConnection.close();
    }

    const ws = new WebSocket(
      `wss://api.example.com/notifications/${userId}`
    );

    ws.onopen = () => {
      console.log('WebSocket接続が確立されました');
      set(connectionStatusAtom, 'connected');
    };

    ws.onmessage = (event) => {
      try {
        const notification = JSON.parse(event.data);
        set(addNotificationAction, notification);
      } catch (error) {
        console.error('通知データの解析に失敗:', error);
      }
    };

    ws.onclose = () => {
      console.log('WebSocket接続が閉じられました');
      set(connectionStatusAtom, 'disconnected');

      // 自動再接続ロジック
      setTimeout(() => {
        set(connectWebSocketAction, userId);
      }, 5000);
    };

    ws.onerror = (error) => {
      console.error('WebSocketエラー:', error);
      set(connectionStatusAtom, 'error');
    };

    set(websocketConnectionAtom, ws);
  }
);

// 通知追加アクション
const addNotificationAction = atom(
  null,
  (get, set, notification: Notification) => {
    const currentNotifications = get(notificationsAtom);

    // 重複チェック
    if (
      currentNotifications.some(
        (n) => n.id === notification.id
      )
    ) {
      return;
    }

    // 通知を先頭に追加
    const updatedNotifications = [
      notification,
      ...currentNotifications,
    ];

    // 最大100件まで保持
    const trimmedNotifications = updatedNotifications.slice(
      0,
      100
    );

    set(notificationsAtom, trimmedNotifications);

    // 重要度に応じてブラウザ通知を表示
    if (
      notification.priority === 'high' &&
      'Notification' in window
    ) {
      set(showBrowserNotificationAction, notification);
    }

    // 音声通知
    if (notification.type === 'message') {
      set(playNotificationSoundAction);
    }
  }
);

// ブラウザ通知表示アクション
const showBrowserNotificationAction = atom(
  null,
  async (get, set, notification: Notification) => {
    if (!('Notification' in window)) {
      return;
    }

    if (Notification.permission === 'default') {
      const permission =
        await Notification.requestPermission();
      if (permission !== 'granted') {
        return;
      }
    }

    if (Notification.permission === 'granted') {
      const browserNotification = new Notification(
        notification.title,
        {
          body: notification.message,
          icon: notification.icon || '/favicon.ico',
          tag: notification.id,
        }
      );

      browserNotification.onclick = () => {
        // 通知クリック時の処理
        set(markAsReadAction, notification.id);
        browserNotification.close();

        // 関連ページに遷移
        if (notification.actionUrl) {
          window.open(notification.actionUrl, '_blank');
        }
      };
    }
  }
);

テスタビリティとメンテナンス性の劇的改善

単体テストの簡素化

アクション atom を使用することで、ビジネスロジックの単体テストが格段に簡単になります。

typescript// テスト例:ショッピングカートのビジネスロジック
import { describe, it, expect, beforeEach } from 'vitest';
import { createStore } from 'jotai';

describe('Shopping Cart Actions', () => {
  let store: ReturnType<typeof createStore>;

  beforeEach(() => {
    store = createStore();
  });

  describe('addToCartAction', () => {
    it('should add new product to empty cart', async () => {
      const product = {
        id: '1',
        name: 'Test Product',
        price: 1000,
      };

      // アクション実行
      await store.set(addToCartAction, product, 2);

      // 結果検証
      const items = store.get(cartItemsAtom);
      expect(items).toHaveLength(1);
      expect(items[0]).toEqual({
        ...product,
        quantity: 2,
      });
    });

    it('should update quantity for existing product', async () => {
      const product = {
        id: '1',
        name: 'Test Product',
        price: 1000,
      };

      // 初期状態設定
      store.set(cartItemsAtom, [
        { ...product, quantity: 1 },
      ]);

      // アクション実行
      await store.set(addToCartAction, product, 2);

      // 結果検証
      const items = store.get(cartItemsAtom);
      expect(items).toHaveLength(1);
      expect(items[0].quantity).toBe(3);
    });
  });

  describe('calculateCartTotalsAction', () => {
    it('should calculate correct totals with discount', async () => {
      // テストデータ設定
      store.set(cartItemsAtom, [
        {
          id: '1',
          name: 'Product 1',
          price: 1000,
          quantity: 2,
        },
        {
          id: '2',
          name: 'Product 2',
          price: 1500,
          quantity: 1,
        },
      ]);

      store.set(userTierAtom, 'premium');

      // アクション実行
      await store.set(calculateCartTotalsAction);

      // 結果検証
      const totals = store.get(cartMetaAtom);
      expect(totals.subtotal).toBe(3500);
      expect(totals.discount).toBe(175); // 5%割引
      expect(totals.shipping).toBe(0); // 送料無料
      expect(totals.tax).toBe(332.5); // 10%税金
      expect(totals.total).toBe(3657.5);
    });
  });
});

統合テストの効率化

複数のアクション atom を組み合わせた統合テストも簡潔に記述できます。

typescriptdescribe('Cart Integration Tests', () => {
  it('should handle complete purchase flow', async () => {
    const store = createStore();
    const product = {
      id: '1',
      name: 'Test Product',
      price: 2000,
    };

    // 1. 商品をカートに追加
    await store.set(addToCartAction, product, 3);

    // 2. 合計金額が正しく計算されることを確認
    let totals = store.get(cartMetaAtom);
    expect(totals.subtotal).toBe(6000);

    // 3. 注文確定
    const paymentInfo = {
      method: 'credit',
      cardToken: 'test-token',
    };
    const result = await store.set(
      checkoutAction,
      paymentInfo
    );

    // 4. 注文成功とカートクリアを確認
    expect(result.success).toBe(true);
    const finalItems = store.get(cartItemsAtom);
    expect(finalItems).toHaveLength(0);
  });
});

モックの簡素化

外部依存関係のモックも簡単に設定できます。

typescript// APIモック
const mockApi = {
  post: vi.fn(),
  get: vi.fn(),
  put: vi.fn(),
  delete: vi.fn(),
};

// グローバルに設定
vi.mock('../api', () => ({
  api: mockApi,
}));

describe('API Integration Tests', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

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

    // APIエラーをシミュレート
    mockApi.post.mockRejectedValue(
      new Error('Network Error')
    );

    const result = await store.set(checkoutAction, {
      method: 'credit',
    });

    expect(result.success).toBe(false);
    expect(result.error).toBe('Network Error');
  });
});

デバッグとロギングの改善

アクション atom にログ機能を組み込むことで、デバッグが容易になります。

typescript// ロギング機能付きアクション atom
const createLoggableAction = <T extends any[], R>(
  name: string,
  action: (get: Getter, set: Setter, ...args: T) => R
) => {
  return atom(null, (get, set, ...args: T) => {
    console.group(`🎯 Action: ${name}`);
    console.log('Arguments:', args);
    console.time(`${name} execution time`);

    try {
      const result = action(get, set, ...args);

      if (result instanceof Promise) {
        return result
          .then((res) => {
            console.log('Result:', res);
            console.timeEnd(`${name} execution time`);
            console.groupEnd();
            return res;
          })
          .catch((error) => {
            console.error('Error:', error);
            console.timeEnd(`${name} execution time`);
            console.groupEnd();
            throw error;
          });
      } else {
        console.log('Result:', result);
        console.timeEnd(`${name} execution time`);
        console.groupEnd();
        return result;
      }
    } catch (error) {
      console.error('Error:', error);
      console.timeEnd(`${name} execution time`);
      console.groupEnd();
      throw error;
    }
  });
};

// 使用例
const addToCartActionWithLogging = createLoggableAction(
  'addToCart',
  (get, set, product: Product, quantity: number) => {
    // 元のロジック
  }
);

パフォーマンス監視

アクション atom の実行時間や頻度を監視することで、パフォーマンスの問題を早期発見できます。

typescript// パフォーマンス監視機能
const performanceMetricsAtom = atom<
  Map<string, PerformanceMetric>
>(new Map());

const createMonitoredAction = <T extends any[], R>(
  name: string,
  action: (get: Getter, set: Setter, ...args: T) => R
) => {
  return atom(null, async (get, set, ...args: T) => {
    const startTime = performance.now();

    try {
      const result = await action(get, set, ...args);

      const endTime = performance.now();
      const executionTime = endTime - startTime;

      // メトリクス更新
      const currentMetrics = get(performanceMetricsAtom);
      const existing = currentMetrics.get(name) || {
        totalCalls: 0,
        totalTime: 0,
        avgTime: 0,
        maxTime: 0,
        minTime: Infinity,
      };

      const updated = {
        totalCalls: existing.totalCalls + 1,
        totalTime: existing.totalTime + executionTime,
        avgTime:
          (existing.totalTime + executionTime) /
          (existing.totalCalls + 1),
        maxTime: Math.max(existing.maxTime, executionTime),
        minTime: Math.min(existing.minTime, executionTime),
      };

      set(
        performanceMetricsAtom,
        new Map(currentMetrics).set(name, updated)
      );

      return result;
    } catch (error) {
      // エラー時もメトリクスを記録
      throw error;
    }
  });
};

まとめ

アクション atom を活用したビジネスロジックと UI の分離は、React 開発における革新的なアプローチです。この手法により、以下のような大きなメリットを得ることができます。

得られる具体的なメリット

1. 開発効率の大幅向上

  • コードの再利用性: 同じビジネスロジックを複数のコンポーネントで活用
  • 並行開発の促進: UI とロジックを独立して開発可能
  • デバッグの簡素化: 問題の切り分けが容易

2. 品質とメンテナンス性の改善

  • テストカバレッジの向上: ビジネスロジックの単体テストが容易
  • バグの早期発見: 純粋関数としてのテストにより信頼性向上
  • 変更の影響範囲の限定: 関心の分離により安全な修正が可能

3. チーム開発の効率化

  • 責任の明確化: UI とロジックの担当を分離可能
  • コードレビューの質向上: 小さな単位での確認が可能
  • 新メンバーの理解促進: 構造化されたコードで学習コストを削減

導入時の注意点

アクション atom を導入する際は、以下の点にご注意ください:

  • 段階的な導入: 既存プロジェクトでは少しずつ移行を進める
  • チーム内での合意: 設計方針をチーム全体で共有
  • 適切な粒度: atom を細かくしすぎず、適切な責任範囲で設計

今後の発展可能性

アクション atom の概念は、以下のような発展の可能性を秘めています:

  • マイクロサービスとの連携: 各サービスの API と atom の対応
  • 状態管理の標準化: プロジェクト間でのパターン統一
  • 自動テスト生成: atom の型情報からテストケースの自動生成

React 開発において、ビジネスロジックと UI の適切な分離は、もはや選択肢ではなく必須の要件となっています。Jotai のアクション atom を活用することで、より保守性が高く、テストしやすい、そして拡張性に優れたアプリケーションを構築していきましょう。

この記事で紹介した手法を実際のプロジェクトで試してみて、その効果を体感していただければと思います。きっと、従来の開発方法では得られなかった開発体験を味わっていただけるはずです。

関連リンク