T-CREATOR

Jotai を 120%活用するユーティリティライブラリ(jotai-immer, jotai-xstate, jotai-form)の世界

Jotai を 120%活用するユーティリティライブラリ(jotai-immer, jotai-xstate, jotai-form)の世界

Jotai の基本を理解した方に向けて、より高度な活用方法を紹介します。jotai-immer、jotai-xstate、jotai-form という 3 つの強力なユーティリティライブラリを使いこなすことで、状態管理の可能性が大きく広がります。

これらのライブラリを組み合わせることで、複雑な状態管理も驚くほどシンプルになり、開発効率が劇的に向上します。実際のプロジェクトで直面する課題を解決しながら、Jotai の真の力を体験していきましょう。

Jotai の基本とユーティリティライブラリの役割

Jotai の特徴と基本概念の復習

Jotai は、React の状態管理ライブラリとして、原子(atom)ベースのアプローチを採用しています。その特徴は以下の通りです。

typescript// 基本的なatomの定義
import { atom } from 'jotai';

// プリミティブな値のatom
const countAtom = atom(0);

// 派生atom(computed value)
const doubleCountAtom = atom((get) => get(countAtom) * 2);

// 書き込み可能な派生atom
const incrementAtom = atom(
  (get) => get(countAtom),
  (get, set) => set(countAtom, get(countAtom) + 1)
);

Jotai の最大の魅力は、そのシンプルさと柔軟性にあります。しかし、複雑なアプリケーションを開発する際には、単純な atom だけでは対応できない課題が発生します。

ユーティリティライブラリが解決する課題

実際の開発では、以下のような課題に直面することが多いでしょう。

課題 1:複雑なオブジェクトの更新

typescript// 従来の方法 - 深いネストの更新が煩雑
const userAtom = atom({
  profile: {
    personal: {
      name: '田中太郎',
      age: 30,
      address: {
        city: '東京',
        postalCode: '100-0001',
      },
    },
  },
});

// 更新が複雑になる
const updateUserCityAtom = atom(
  (get) => get(userAtom),
  (get, set, newCity: string) => {
    const user = get(userAtom);
    set(userAtom, {
      ...user,
      profile: {
        ...user.profile,
        personal: {
          ...user.profile.personal,
          address: {
            ...user.profile.personal.address,
            city: newCity,
          },
        },
      },
    });
  }
);

課題 2:複雑な状態遷移の管理

typescript// 複数の状態を管理する場合
const isLoadingAtom = atom(false);
const errorAtom = atom<string | null>(null);
const dataAtom = atom<any>(null);

// 状態遷移の管理が複雑
const fetchDataAtom = atom(
  (get) => ({
    isLoading: get(isLoadingAtom),
    error: get(errorAtom),
    data: get(dataAtom),
  }),
  async (get, set) => {
    set(isLoadingAtom, true);
    set(errorAtom, null);

    try {
      const data = await fetch('/api/data');
      set(dataAtom, data);
    } catch (error) {
      set(errorAtom, error.message);
    } finally {
      set(isLoadingAtom, false);
    }
  }
);

課題 3:フォーム状態の管理

typescript// フォームの各フィールドを個別に管理
const nameAtom = atom('');
const emailAtom = atom('');
const passwordAtom = atom('');

// バリデーションも個別に管理
const nameErrorAtom = atom((get) => {
  const name = get(nameAtom);
  return name.length < 2
    ? '名前は2文字以上で入力してください'
    : null;
});

// フォーム全体の状態を取得するのが困難
const formStateAtom = atom((get) => ({
  values: {
    name: get(nameAtom),
    email: get(emailAtom),
    password: get(passwordAtom),
  },
  errors: {
    name: get(nameErrorAtom),
    email: get(emailErrorAtom),
    password: get(passwordErrorAtom),
  },
  isValid:
    !get(nameErrorAtom) &&
    !get(emailErrorAtom) &&
    !get(passwordErrorAtom),
}));

3 つのライブラリの概要と使い分け

これらの課題を解決するために、以下の 3 つのユーティリティライブラリが存在します。

ライブラリ解決する課題適用場面
jotai-immer複雑なオブジェクト更新ネストしたオブジェクトの更新、配列操作
jotai-xstate複雑な状態遷移非同期処理、状態機械、ワークフロー
jotai-formフォーム状態管理フォーム入力、バリデーション、送信処理

まずは、これらのライブラリをインストールしましょう。

bashyarn add jotai-immer jotai-xstate jotai-form immer xstate

jotai-immer で状態更新をシンプルに

Immer の基本概念と Jotai との組み合わせ

Immer は、イミュータブルな更新をミュータブルな書き方で実現するライブラリです。Jotai と組み合わせることで、複雑なオブジェクト更新が驚くほどシンプルになります。

typescriptimport { atom } from 'jotai';
import { atomWithImmer } from 'jotai-immer';

// immerを使用したatomの定義
const userAtom = atomWithImmer({
  profile: {
    personal: {
      name: '田中太郎',
      age: 30,
      address: {
        city: '東京',
        postalCode: '100-0001',
      },
    },
  },
});

複雑なオブジェクト更新の簡素化

jotai-immer を使うことで、深いネストの更新も直感的に書けるようになります。

typescript// 更新用のatom
const updateUserAtom = atom(
  (get) => get(userAtom),
  (get, set, updates: Partial<typeof userAtom._init>) => {
    set(userAtom, (draft) => {
      // ミュータブルな書き方でイミュータブルな更新を実現
      Object.assign(draft, updates);
    });
  }
);

// 特定のプロパティを更新
const updateUserNameAtom = atom(
  (get) => get(userAtom),
  (get, set, name: string) => {
    set(userAtom, (draft) => {
      draft.profile.personal.name = name;
    });
  }
);

// 配列の操作も簡単
const addHobbyAtom = atom(
  (get) => get(userAtom),
  (get, set, hobby: string) => {
    set(userAtom, (draft) => {
      if (!draft.profile.personal.hobbies) {
        draft.profile.personal.hobbies = [];
      }
      draft.profile.personal.hobbies.push(hobby);
    });
  }
);

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

EC サイトのカート機能を例に、jotai-immer の実践的な活用を見てみましょう。

typescript// カートの状態定義
const cartAtom = atomWithImmer({
  items: [] as CartItem[],
  total: 0,
  discount: 0,
});

// 商品をカートに追加
const addToCartAtom = atom(
  (get) => get(cartAtom),
  (get, set, item: CartItem) => {
    set(cartAtom, (draft) => {
      const existingItem = draft.items.find(
        (i) => i.id === item.id
      );

      if (existingItem) {
        existingItem.quantity += item.quantity;
      } else {
        draft.items.push(item);
      }

      // 合計金額を再計算
      draft.total = draft.items.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
      );
    });
  }
);

// 商品の数量を更新
const updateQuantityAtom = atom(
  (get) => get(cartAtom),
  (
    get,
    set,
    {
      itemId,
      quantity,
    }: { itemId: string; quantity: number }
  ) => {
    set(cartAtom, (draft) => {
      const item = draft.items.find((i) => i.id === itemId);
      if (item) {
        item.quantity = Math.max(0, quantity);
        // 数量が0になったら削除
        if (item.quantity === 0) {
          draft.items = draft.items.filter(
            (i) => i.id !== itemId
          );
        }
        // 合計金額を再計算
        draft.total = draft.items.reduce(
          (sum, item) => sum + item.price * item.quantity,
          0
        );
      }
    });
  }
);

jotai-xstate で状態機械を統合

XState の基本と Jotai との連携

XState は、状態機械(State Machine)を実装するためのライブラリです。複雑な状態遷移や非同期処理を、視覚的で理解しやすい形で管理できます。

typescriptimport { atom } from 'jotai';
import { atomWithMachine } from 'jotai-xstate';
import { createMachine } from 'xstate';

// データ取得の状態機械を定義
const dataFetchMachine = createMachine({
  id: 'dataFetch',
  initial: 'idle',
  states: {
    idle: {
      on: { FETCH: 'loading' },
    },
    loading: {
      on: {
        RESOLVE: 'success',
        REJECT: 'error',
      },
    },
    success: {
      on: { FETCH: 'loading' },
    },
    error: {
      on: { FETCH: 'loading' },
    },
  },
});

// JotaiとXStateを統合
const dataFetchAtom = atomWithMachine(dataFetchMachine);

複雑な状態遷移の管理

XState を使うことで、複雑な状態遷移も明確に管理できます。

typescript// より複雑な状態機械の例
const userAuthMachine = createMachine({
  id: 'userAuth',
  initial: 'unauthenticated',
  context: {
    user: null,
    error: null,
  },
  states: {
    unauthenticated: {
      on: { LOGIN: 'authenticating' },
    },
    authenticating: {
      on: {
        SUCCESS: {
          target: 'authenticated',
          actions: 'setUser',
        },
        ERROR: {
          target: 'error',
          actions: 'setError',
        },
      },
    },
    authenticated: {
      on: { LOGOUT: 'unauthenticated' },
    },
    error: {
      on: { RETRY: 'authenticating' },
    },
  },
});

// アクションの定義
const userAuthAtom = atomWithMachine(userAuthMachine, {
  actions: {
    setUser: (context, event) => {
      context.user = event.user;
    },
    setError: (context, event) => {
      context.error = event.error;
    },
  },
});

実践的なユースケース

オンライン決済システムの例で、XState の実践的な活用を見てみましょう。

typescript// 決済処理の状態機械
const paymentMachine = createMachine({
  id: 'payment',
  initial: 'ready',
  context: {
    amount: 0,
    paymentMethod: null,
    transactionId: null,
    error: null,
  },
  states: {
    ready: {
      on: { START_PAYMENT: 'validating' },
    },
    validating: {
      on: {
        VALIDATION_SUCCESS: 'processing',
        VALIDATION_ERROR: 'error',
      },
    },
    processing: {
      on: {
        PAYMENT_SUCCESS: 'success',
        PAYMENT_ERROR: 'error',
      },
    },
    success: {
      type: 'final',
    },
    error: {
      on: { RETRY: 'ready' },
    },
  },
});

// Jotaiとの統合
const paymentAtom = atomWithMachine(paymentMachine);

// 決済処理の実行
const executePaymentAtom = atom(
  (get) => get(paymentAtom),
  async (get, set, { amount, paymentMethod }) => {
    const service = get(paymentAtom);

    // 決済開始
    service.send({ type: 'START_PAYMENT' });

    try {
      // バリデーション
      await validatePayment(amount, paymentMethod);
      service.send({ type: 'VALIDATION_SUCCESS' });

      // 決済処理
      const result = await processPayment(
        amount,
        paymentMethod
      );
      service.send({
        type: 'PAYMENT_SUCCESS',
        transactionId: result.transactionId,
      });
    } catch (error) {
      service.send({
        type: 'PAYMENT_ERROR',
        error: error.message,
      });
    }
  }
);

jotai-form でフォーム管理を効率化

フォーム状態の一元管理

jotai-form は、フォームの状態管理を大幅に簡素化します。従来の複雑なフォーム管理が、驚くほどシンプルになります。

typescriptimport { atom } from 'jotai';
import { atomWithForm } from 'jotai-form';

// フォームのスキーマ定義
const userFormSchema = {
  name: { type: 'string', required: true, minLength: 2 },
  email: { type: 'email', required: true },
  age: { type: 'number', min: 18, max: 100 },
  hobbies: { type: 'array', items: { type: 'string' } },
};

// フォームatomの作成
const userFormAtom = atomWithForm(userFormSchema, {
  initialValues: {
    name: '',
    email: '',
    age: 25,
    hobbies: [],
  },
});

バリデーションとの連携

jotai-form は、バリデーションも自動的に処理してくれます。

typescript// カスタムバリデーションの追加
const userFormAtom = atomWithForm(userFormSchema, {
  initialValues: {
    name: '',
    email: '',
    age: 25,
    hobbies: [],
  },
  validate: (values) => {
    const errors: Record<string, string> = {};

    // カスタムバリデーション
    if (values.name && values.name.length < 2) {
      errors.name = '名前は2文字以上で入力してください';
    }

    if (values.email && !values.email.includes('@')) {
      errors.email =
        '有効なメールアドレスを入力してください';
    }

    return errors;
  },
});

// フォームの状態を取得
const formStateAtom = atom((get) => {
  const form = get(userFormAtom);
  return {
    values: form.values,
    errors: form.errors,
    touched: form.touched,
    isValid: form.isValid,
    isDirty: form.isDirty,
  };
});

パフォーマンス最適化

jotai-form は、パフォーマンスも考慮されています。

typescript// フォームフィールドの個別atom
const nameFieldAtom = atom(
  (get) => get(userFormAtom).values.name,
  (get, set, value: string) => {
    set(userFormAtom, (draft) => {
      draft.values.name = value;
      draft.touched.name = true;
    });
  }
);

// エラー状態の個別atom
const nameErrorAtom = atom((get) => {
  const form = get(userFormAtom);
  return form.touched.name ? form.errors.name : null;
});

// コンポーネントでの使用
const NameField = () => {
  const [name, setName] = useAtom(nameFieldAtom);
  const [error] = useAtom(nameErrorAtom);

  return (
    <div>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        className={error ? 'error' : ''}
      />
      {error && (
        <span className='error-message'>{error}</span>
      )}
    </div>
  );
};

3 つのライブラリを組み合わせた実践例

実際のアプリケーションでの統合活用

これまで紹介した 3 つのライブラリを組み合わせて、実際のアプリケーションを作成してみましょう。EC サイトの商品管理システムを例にします。

typescript// 商品データの型定義
interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
  category: string;
  description: string;
}

// 商品リストの状態管理(jotai-immer)
const productsAtom = atomWithImmer<Product[]>([]);

// 商品の追加
const addProductAtom = atom(
  (get) => get(productsAtom),
  (get, set, product: Omit<Product, 'id'>) => {
    set(productsAtom, (draft) => {
      draft.push({
        ...product,
        id: generateId(),
      });
    });
  }
);

// 商品の更新
const updateProductAtom = atom(
  (get) => get(productsAtom),
  (
    get,
    set,
    {
      id,
      updates,
    }: { id: string; updates: Partial<Product> }
  ) => {
    set(productsAtom, (draft) => {
      const product = draft.find((p) => p.id === id);
      if (product) {
        Object.assign(product, updates);
      }
    });
  }
);

商品管理の状態遷移を XState で管理します。

typescript// 商品管理の状態機械
const productManagementMachine = createMachine({
  id: 'productManagement',
  initial: 'idle',
  context: {
    selectedProduct: null,
    error: null,
  },
  states: {
    idle: {
      on: {
        SELECT_PRODUCT: 'editing',
        ADD_PRODUCT: 'creating',
      },
    },
    editing: {
      on: {
        SAVE: 'saving',
        CANCEL: 'idle',
        DELETE: 'confirming',
      },
    },
    creating: {
      on: {
        SAVE: 'saving',
        CANCEL: 'idle',
      },
    },
    saving: {
      on: {
        SUCCESS: 'idle',
        ERROR: 'error',
      },
    },
    confirming: {
      on: {
        CONFIRM: 'deleting',
        CANCEL: 'editing',
      },
    },
    deleting: {
      on: {
        SUCCESS: 'idle',
        ERROR: 'error',
      },
    },
    error: {
      on: { RETRY: 'idle' },
    },
  },
});

// 状態機械とJotaiの統合
const productManagementAtom = atomWithMachine(
  productManagementMachine
);

商品編集フォームを jotai-form で管理します。

typescript// 商品フォームのスキーマ
const productFormSchema = {
  name: { type: 'string', required: true, minLength: 1 },
  price: { type: 'number', required: true, min: 0 },
  stock: { type: 'number', required: true, min: 0 },
  category: { type: 'string', required: true },
  description: {
    type: 'string',
    required: true,
    minLength: 10,
  },
};

// 商品フォームatom
const productFormAtom = atomWithForm(productFormSchema, {
  initialValues: {
    name: '',
    price: 0,
    stock: 0,
    category: '',
    description: '',
  },
});

// 商品管理コンポーネント
const ProductManagement = () => {
  const [products] = useAtom(productsAtom);
  const [managementState] = useAtom(productManagementAtom);
  const [productForm] = useAtom(productFormAtom);

  return (
    <div className='product-management'>
      <div className='product-list'>
        {products.map((product) => (
          <ProductCard
            key={product.id}
            product={product}
            onEdit={() => handleEdit(product)}
          />
        ))}
      </div>

      {managementState.matches('editing') && (
        <ProductEditForm
          form={productForm}
          onSubmit={handleSave}
          onCancel={handleCancel}
        />
      )}

      {managementState.matches('creating') && (
        <ProductCreateForm
          form={productForm}
          onSubmit={handleCreate}
          onCancel={handleCancel}
        />
      )}
    </div>
  );
};

ベストプラクティスと注意点

3 つのライブラリを組み合わせる際の重要なポイントを紹介します。

1. 適切な責務分離

typescript// 良い例:各ライブラリの役割を明確に分離
const userAtom = atomWithImmer({ name: '', email: '' }); // データ状態
const authMachineAtom = atomWithMachine(authMachine); // 認証フロー
const profileFormAtom = atomWithForm(profileSchema); // フォーム状態

// 悪い例:一つのatomに全てを詰め込む
const userAtom = atom({
  data: { name: '', email: '' },
  authState: 'idle',
  formErrors: {},
  // ... 他の状態も混在
});

2. パフォーマンスの考慮

typescript// 大きなオブジェクトの分割
const largeDataAtom = atomWithImmer({
  users: [],
  products: [],
  orders: [],
});

// 個別のatomに分割
const usersAtom = atomWithImmer([]);
const productsAtom = atomWithImmer([]);
const ordersAtom = atomWithImmer([]);

// 必要に応じて統合
const allDataAtom = atom((get) => ({
  users: get(usersAtom),
  products: get(productsAtom),
  orders: get(ordersAtom),
}));

3. エラーハンドリング

typescript// エラー状態の統一管理
const errorAtom = atom<string | null>(null);

// 各ライブラリでのエラー処理
const handleError = (error: Error) => {
  set(errorAtom, error.message);
};

// XStateでのエラー処理
const machineAtom = atomWithMachine(machine, {
  actions: {
    handleError: (context, event) => {
      set(errorAtom, event.error);
    },
  },
});

// jotai-formでのエラー処理
const formAtom = atomWithForm(schema, {
  validate: (values) => {
    try {
      return validateSchema(values);
    } catch (error) {
      set(errorAtom, error.message);
      return {};
    }
  },
});

パフォーマンスの考慮事項

大規模アプリケーションでのパフォーマンス最適化について説明します。

1. メモ化の活用

typescriptimport { useMemo } from 'react';

// 重い計算のメモ化
const expensiveCalculationAtom = atom((get) => {
  const data = get(largeDataAtom);
  return useMemo(() => {
    return data
      .filter((item) => item.isActive)
      .map((item) => ({ ...item, processed: true }))
      .sort((a, b) => a.name.localeCompare(b.name));
  }, [data]);
});

2. 分割読み込み

typescript// 必要な部分のみを読み込み
const userProfileAtom = atom((get) => {
  const user = get(userAtom);
  return {
    name: user.name,
    email: user.email,
    // 他のフィールドは必要に応じて読み込み
  };
});

3. バッチ更新

typescript// 複数の更新をバッチで実行
const batchUpdateAtom = atom(
  (get) => get(dataAtom),
  (get, set, updates: Update[]) => {
    set(dataAtom, (draft) => {
      updates.forEach((update) => {
        // 全ての更新を一度に適用
        applyUpdate(draft, update);
      });
    });
  }
);

まとめ

Jotai のユーティリティライブラリ(jotai-immer、jotai-xstate、jotai-form)を活用することで、状態管理の可能性が大きく広がります。

jotai-immerにより、複雑なオブジェクト更新が直感的で読みやすいコードになります。深いネストの更新や配列操作も、ミュータブルな書き方でイミュータブルな更新を実現できます。

jotai-xstateを使うことで、複雑な状態遷移や非同期処理を視覚的で理解しやすい形で管理できます。特に、ユーザーの操作フローやビジネスロジックの管理に威力を発揮します。

jotai-formにより、フォーム管理が劇的に簡素化されます。バリデーションやエラーハンドリングも自動化され、開発効率が大幅に向上します。

これら 3 つのライブラリを組み合わせることで、従来では複雑で管理が困難だった状態管理も、驚くほどシンプルで保守性の高いコードになります。

実際のプロジェクトでこれらのライブラリを活用する際は、適切な責務分離とパフォーマンスの考慮が重要です。各ライブラリの特徴を理解し、適切な場面で使い分けることで、最大の効果を得ることができます。

Jotai の基本をマスターした次のステップとして、これらのユーティリティライブラリの世界に踏み込んでみてください。きっと、状態管理に対する新しい視点と可能性を発見できるはずです。

関連リンク