T-CREATOR

Jotai 全体像を一枚で理解:Atom・派生・非同期の関係を図解

Jotai 全体像を一枚で理解:Atom・派生・非同期の関係を図解

React の状態管理に関する課題は複雑になる一方です。コンポーネント間でのデータ共有、不要な再レンダリングの防止、非同期データの管理など、これらすべてを効率的に解決する方法をお探しではないでしょうか。

Jotai(ジョータイ)は、これらの問題を解決するために設計された、原子的(atomic)アプローチを採用した状態管理ライブラリです。今回は、Jotai の全体像を図解を交えながら、初心者の方でも理解できるよう詳しく解説いたします。

Jotai の基本アーキテクチャ

Jotai のアーキテクチャは、シンプルながら非常に強力な設計となっています。まずは全体像を把握していきましょう。

Jotai の核となる概念をフローチャートで確認してみましょう。

mermaidflowchart TB
    user[ユーザー操作] --> component[Reactコンポーネント]
    component --> |useAtom| atom[基本Atom]
    component --> |useAtomValue| derived[派生Atom]
    component --> |useAtomValue| async[非同期Atom]

    atom --> |計算処理| derived
    derived --> |再計算| component

    async --> |データフェッチ| api[外部API]
    api --> |結果| async
    async --> |更新| component

    atom --> store[Jotaiストア]
    derived --> store
    async --> store
    store --> |状態管理| component

この図から分かるように、Jotai は Atom という小さな状態の単位を中心として、派生計算や非同期処理を統合的に管理します。

Atom の役割と仕組み

Atom は Jotai における最小の状態管理単位です。Redux の store 全体に対して、Atom は個別の状態を管理する小さな容器として機能します。

Atom の基本的な定義方法を見てみましょう。

typescriptimport { atom } from 'jotai';

// 基本的なAtomの定義
const countAtom = atom(0);
const nameAtom = atom('初期値');
const isLoadingAtom = atom(false);

Atom は読み取り専用と読み書き可能な 2 つの形態があります。

typescript// 読み取り専用Atom
const readOnlyAtom = atom((get) => get(countAtom) * 2);

// 読み書き可能Atom
const incrementAtom = atom(
  (get) => get(countAtom),
  (get, set, newValue: number) => set(countAtom, newValue)
);

React コンポーネントでの使用方法は以下のようになります。

typescriptimport { useAtom, useAtomValue, useSetAtom } from 'jotai';

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const doubleCount = useAtomValue(readOnlyAtom);
  const increment = useSetAtom(incrementAtom);

  return (
    <div>
      <p>カウント: {count}</p>
      <p>2倍: {doubleCount}</p>
      <button onClick={() => increment(count + 1)}>
        増加
      </button>
    </div>
  );
}

派生 Atom の概念

派生 Atom は他の Atom の値を基に計算される Atom です。この仕組みにより、複雑な状態の依存関係を効率的に管理できます。

派生 Atom の依存関係を図で表現してみましょう。

mermaidflowchart LR
    base1[基本Atom1] --> derived1[派生Atom1]
    base2[基本Atom2] --> derived1
    base1 --> derived2[派生Atom2]
    derived1 --> final[最終派生Atom]
    derived2 --> final

    style base1 fill:#e1f5fe
    style base2 fill:#e1f5fe
    style derived1 fill:#f3e5f5
    style derived2 fill:#f3e5f5
    style final fill:#e8f5e8

派生 Atom の具体的な実装例を見てみましょう。

typescript// 基本的な状態
const firstNameAtom = atom('太郎');
const lastNameAtom = atom('田中');
const ageAtom = atom(25);

// 派生Atom:フルネーム
const fullNameAtom = atom(
  (get) => `${get(lastNameAtom)} ${get(firstNameAtom)}`
);

// 派生Atom:成人判定
const isAdultAtom = atom((get) => get(ageAtom) >= 18);

より複雑な派生 Atom の例として、ショッピングカートの計算を実装してみましょう。

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

interface CartItem {
  product: Product;
  quantity: number;
}

// 基本状態
const cartItemsAtom = atom<CartItem[]>([]);
const discountRateAtom = atom(0.1); // 10%割引

// 派生Atom:合計金額
const totalPriceAtom = atom((get) => {
  const items = get(cartItemsAtom);
  return items.reduce(
    (total, item) =>
      total + item.product.price * item.quantity,
    0
  );
});

// 派生Atom:割引適用後金額
const discountedPriceAtom = atom((get) => {
  const total = get(totalPriceAtom);
  const discount = get(discountRateAtom);
  return total * (1 - discount);
});

非同期 Atom の特徴

非同期 Atom は API 呼び出しやデータベースアクセスなど、時間のかかる処理を Atom として扱える仕組みです。

非同期処理のライフサイクルを図で確認してみましょう。

mermaidsequenceDiagram
    participant C as コンポーネント
    participant A as 非同期Atom
    participant API as 外部API

    C->>A: データ要求
    A->>C: Suspense(ローディング)
    A->>API: API呼び出し
    API-->>A: データ取得完了
    A->>C: データ提供

    Note over C,API: エラー時
    API-->>A: エラー発生
    A->>C: ErrorBoundary

非同期 Atom の基本的な実装方法です。

typescript// ユーザー情報を取得する非同期Atom
const userAtom = atom(async () => {
  const response = await fetch('/api/user');
  if (!response.ok) {
    throw new Error('ユーザー情報の取得に失敗しました');
  }
  return response.json();
});

// パラメータ付きの非同期Atom
const userByIdAtom = atom(async (get) => {
  const userId = get(userIdAtom);
  if (!userId) return null;

  const response = await fetch(`/api/users/${userId}`);
  return response.json();
});

非同期 Atom をコンポーネントで使用する際は、Suspense と ErrorBoundary が必要です。

typescriptimport { Suspense } from 'react';
import { useAtomValue } from 'jotai';

function UserProfile() {
  const user = useAtomValue(userAtom);

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// Suspenseでラップ
function App() {
  return (
    <Suspense fallback={<div>読み込み中...</div>}>
      <UserProfile />
    </Suspense>
  );
}

Atom・派生・非同期の関係性

ここからは、Jotai の各要素がどのように連携して動作するかを詳しく見ていきましょう。

データフローの全体像

Jotai におけるデータフローは、単方向でありながら複雑な依存関係を効率的に管理します。

全体的なデータフローを可視化してみましょう。

mermaidflowchart TD
    ui[ユーザーインターフェース] --> action[ユーザーアクション]
    action --> atom1[基本Atom]
    action --> atom2[基本Atom]

    atom1 --> derived1[派生Atom]
    atom2 --> derived1
    atom1 --> derived2[派生Atom]

    derived1 --> async1[非同期Atom]
    derived2 --> async2[非同期Atom]

    async1 --> api1[API呼び出し]
    async2 --> api2[API呼び出し]

    api1 --> cache[キャッシュ]
    api2 --> cache

    cache --> render[再レンダリング]
    render --> ui

    style ui fill:#e3f2fd
    style atom1 fill:#f1f8e9
    style atom2 fill:#f1f8e9
    style derived1 fill:#fff3e0
    style derived2 fill:#fff3e0
    style async1 fill:#fce4ec
    style async2 fill:#fce4ec

この図から、基本 Atom から始まり、派生 Atom、非同期 Atom へと流れる一連のデータフローが見えてきます。

依存関係の仕組み

Jotai の依存関係は自動的に追跡され、必要な時のみ再計算が実行されます。

依存関係のトラッキング例を実装してみましょう。

typescript// 基本データ
const temperatureAtom = atom(25); // 摂氏温度
const unitAtom = atom<'celsius' | 'fahrenheit'>('celsius');

// 派生:表示用温度
const displayTemperatureAtom = atom((get) => {
  const temp = get(temperatureAtom);
  const unit = get(unitAtom);

  if (unit === 'fahrenheit') {
    return (temp * 9) / 5 + 32;
  }
  return temp;
});

// 派生:温度レベル判定
const temperatureLevelAtom = atom((get) => {
  const temp = get(temperatureAtom);

  if (temp < 10) return 'cold';
  if (temp < 25) return 'cool';
  if (temp < 30) return 'warm';
  return 'hot';
});

依存関係の変更による再計算の流れをコンポーネントで確認してみましょう。

typescriptfunction TemperatureDisplay() {
  const [temperature, setTemperature] =
    useAtom(temperatureAtom);
  const [unit, setUnit] = useAtom(unitAtom);
  const displayTemp = useAtomValue(displayTemperatureAtom);
  const level = useAtomValue(temperatureLevelAtom);

  return (
    <div>
      <p>
        温度: {displayTemp}°{unit === 'celsius' ? 'C' : 'F'}
      </p>
      <p>レベル: {level}</p>

      <input
        type='range'
        min='0'
        max='40'
        value={temperature}
        onChange={(e) =>
          setTemperature(Number(e.target.value))
        }
      />

      <button
        onClick={() =>
          setUnit(
            unit === 'celsius' ? 'fahrenheit' : 'celsius'
          )
        }
      >
        単位切り替え
      </button>
    </div>
  );
}

状態更新のメカニズム

Jotai の状態更新は効率的で、変更された Atom とその依存関係のみが更新されます。

状態更新のメカニズムを図で表現してみましょう。

mermaidstateDiagram-v2
    [*] --> Idle: 初期状態

    Idle --> Computing: Atom値変更
    Computing --> Notifying: 計算完了
    Notifying --> Updating: 依存関係通知
    Updating --> Rendering: コンポーネント更新
    Rendering --> Idle: 描画完了

    Computing --> Error: 計算エラー
    Error --> Idle: エラー処理

    Updating --> Batching: 複数更新
    Batching --> Rendering: バッチ処理完了

複雑な状態更新の例として、フォーム管理を実装してみましょう。

typescript// フォームデータの型定義
interface FormData {
  name: string;
  email: string;
  age: number;
}

// 個別フィールドのAtom
const nameAtom = atom('');
const emailAtom = atom('');
const ageAtom = atom(0);

// 派生:フォーム全体のデータ
const formDataAtom = atom<FormData>((get) => ({
  name: get(nameAtom),
  email: get(emailAtom),
  age: get(ageAtom),
}));

// 派生:バリデーション結果
const validationAtom = atom((get) => {
  const form = get(formDataAtom);
  const errors: Partial<FormData> = {};

  if (!form.name.trim()) {
    errors.name = '名前は必須です';
  }

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

  if (form.age < 0 || form.age > 120) {
    errors.age = '年齢は0〜120の間で入力してください';
  }

  return {
    errors,
    isValid: Object.keys(errors).length === 0,
  };
});

フォームコンポーネントでの使用例です。

typescriptfunction FormComponent() {
  const [name, setName] = useAtom(nameAtom);
  const [email, setEmail] = useAtom(emailAtom);
  const [age, setAge] = useAtom(ageAtom);
  const formData = useAtomValue(formDataAtom);
  const validation = useAtomValue(validationAtom);

  const handleSubmit = () => {
    if (validation.isValid) {
      console.log('送信データ:', formData);
    }
  };

  return (
    <form>
      <div>
        <input
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder='名前'
        />
        {validation.errors.name && (
          <span style={{ color: 'red' }}>
            {validation.errors.name}
          </span>
        )}
      </div>

      <div>
        <input
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder='メールアドレス'
        />
        {validation.errors.email && (
          <span style={{ color: 'red' }}>
            {validation.errors.email}
          </span>
        )}
      </div>

      <div>
        <input
          type='number'
          value={age}
          onChange={(e) => setAge(Number(e.target.value))}
          placeholder='年齢'
        />
        {validation.errors.age && (
          <span style={{ color: 'red' }}>
            {validation.errors.age}
          </span>
        )}
      </div>

      <button
        type='button'
        onClick={handleSubmit}
        disabled={!validation.isValid}
      >
        送信
      </button>
    </form>
  );
}

具体的な実装例

実際のアプリケーション開発で使える実装例を通して、Jotai の活用方法を学んでいきましょう。

基本 Atom の作成

基本 Atom の作成パターンをいくつか紹介いたします。

typescript// プリミティブ値のAtom
const countAtom = atom(0);
const messageAtom = atom('初期メッセージ');
const isVisibleAtom = atom(true);

// オブジェクト型のAtom
interface User {
  id: string;
  name: string;
  email: string;
}

const userAtom = atom<User | null>(null);

// 配列型のAtom
const todosAtom = atom<Todo[]>([]);

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

Atom の初期値を動的に設定する方法もあります。

typescript// ローカルストレージから初期値を取得
const themeAtom = atom(
  typeof window !== 'undefined'
    ? localStorage.getItem('theme') || 'light'
    : 'light'
);

// URLパラメータから初期値を設定
const pageAtom = atom(
  typeof window !== 'undefined'
    ? new URLSearchParams(window.location.search).get(
        'page'
      ) || '1'
    : '1'
);

派生 Atom の実装

派生 Atom の実装パターンを実際のユースケースと共に見てみましょう。

typescript// Todo管理の例
const todosAtom = atom<Todo[]>([]);

// 派生:完了済みTodoの数
const completedCountAtom = atom((get) => {
  const todos = get(todosAtom);
  return todos.filter((todo) => todo.completed).length;
});

// 派生:未完了Todoの数
const pendingCountAtom = atom((get) => {
  const todos = get(todosAtom);
  return todos.filter((todo) => !todo.completed).length;
});

// 派生:フィルタリングされたTodo
const filterAtom = atom<'all' | 'completed' | 'pending'>(
  'all'
);

const filteredTodosAtom = atom((get) => {
  const todos = get(todosAtom);
  const filter = get(filterAtom);

  switch (filter) {
    case 'completed':
      return todos.filter((todo) => todo.completed);
    case 'pending':
      return todos.filter((todo) => !todo.completed);
    default:
      return todos;
  }
});

より複雑な計算を行う派生 Atom の例です。

typescript// 売上データの管理
interface SalesData {
  date: string;
  amount: number;
  category: string;
}

const salesDataAtom = atom<SalesData[]>([]);
const selectedMonthAtom = atom(new Date().getMonth());

// 派生:月別売上
const monthlySalesAtom = atom((get) => {
  const sales = get(salesDataAtom);
  const selectedMonth = get(selectedMonthAtom);

  return sales.filter((sale) => {
    const saleDate = new Date(sale.date);
    return saleDate.getMonth() === selectedMonth;
  });
});

// 派生:カテゴリ別売上合計
const categoryTotalsAtom = atom((get) => {
  const monthlySales = get(monthlySalesAtom);

  return monthlySales.reduce((totals, sale) => {
    totals[sale.category] =
      (totals[sale.category] || 0) + sale.amount;
    return totals;
  }, {} as Record<string, number>);
});

非同期処理の組み込み

非同期処理の実装パターンを複数のシナリオで紹介いたします。

typescript// API呼び出しの基本パターン
const postsAtom = atom(async () => {
  const response = await fetch('/api/posts');
  if (!response.ok) {
    throw new Error(
      `HTTP error! status: ${response.status}`
    );
  }
  return response.json() as Post[];
});

// パラメータ付きAPI呼び出し
const postByIdAtom = atom(async (get) => {
  const postId = get(selectedPostIdAtom);
  if (!postId) return null;

  const response = await fetch(`/api/posts/${postId}`);
  if (!response.ok) {
    throw new Error(
      `投稿の取得に失敗しました: ${response.status}`
    );
  }
  return response.json() as Post;
});

キャッシュ機能付きの非同期 Atom の実装例です。

typescript// キャッシュ機能付きAtom
const cacheAtom = atom(new Map<string, any>());

const cachedFetchAtom = atom(async (get) => {
  const cache = get(cacheAtom);
  const url = get(apiUrlAtom);

  // キャッシュから取得を試行
  if (cache.has(url)) {
    return cache.get(url);
  }

  // APIから取得
  const response = await fetch(url);
  const data = await response.json();

  // キャッシュに保存
  cache.set(url, data);

  return data;
});

エラーハンドリングを含む非同期 Atom の実装です。

typescript// エラーハンドリング付きAtom
const userProfileAtom = atom(async (get) => {
  try {
    const userId = get(userIdAtom);
    const response = await fetch(
      `/api/users/${userId}/profile`
    );

    if (!response.ok) {
      if (response.status === 404) {
        throw new Error('ユーザーが見つかりません');
      }
      if (response.status === 403) {
        throw new Error('アクセス権限がありません');
      }
      throw new Error('ユーザー情報の取得に失敗しました');
    }

    return response.json();
  } catch (error) {
    console.error('ユーザープロフィール取得エラー:', error);
    throw error;
  }
});

// 使用例
function UserProfile() {
  const userProfile = useAtomValue(userProfileAtom);

  return (
    <div>
      <h2>{userProfile.name}</h2>
      <p>{userProfile.bio}</p>
    </div>
  );
}

// エラーバウンダリーでラップ
function App() {
  return (
    <ErrorBoundary
      fallback={<div>エラーが発生しました</div>}
    >
      <Suspense fallback={<div>読み込み中...</div>}>
        <UserProfile />
      </Suspense>
    </ErrorBoundary>
  );
}

パフォーマンスと最適化

Jotai を使用する際のパフォーマンス最適化手法を詳しく見ていきましょう。

レンダリング最適化

Jotai は優れたレンダリング最適化機能を持っていますが、さらに効率化する方法があります。

レンダリング最適化の仕組みを図で確認してみましょう。

mermaidflowchart TD
    atom1[Atom A] --> comp1[コンポーネント1]
    atom1 --> comp2[コンポーネント2]
    atom2[Atom B] --> comp2
    atom2 --> comp3[コンポーネント3]

    atom1 --> |変更| trigger[更新トリガー]
    trigger --> |再レンダリング| comp1
    trigger --> |再レンダリング| comp2
    trigger --> |スキップ| comp3

    style comp1 fill:#ffcdd2
    style comp2 fill:#ffcdd2
    style comp3 fill:#c8e6c9

最適化のベストプラクティスを実装してみましょう。

typescript// 適切な粒度でAtomを分割
const userBasicInfoAtom = atom({
  id: '',
  name: '',
  email: '',
});

const userPreferencesAtom = atom({
  theme: 'light',
  language: 'ja',
  notifications: true,
});

// 必要な部分のみを購読
const userNameAtom = atom(
  (get) => get(userBasicInfoAtom).name
);
const userThemeAtom = atom(
  (get) => get(userPreferencesAtom).theme
);

function UserNameDisplay() {
  // nameが変更された時のみ再レンダリング
  const userName = useAtomValue(userNameAtom);

  return <h1>{userName}</h1>;
}

function ThemeToggle() {
  // themeが変更された時のみ再レンダリング
  const [theme, setTheme] = useAtom(userThemeAtom);

  return (
    <button
      onClick={() =>
        setTheme(theme === 'light' ? 'dark' : 'light')
      }
    >
      {theme}テーマ
    </button>
  );
}

memo を使用した最適化の例です。

typescriptimport { memo } from 'react';

// 重い計算を伴うコンポーネント
const ExpensiveComponent = memo(
  function ExpensiveComponent() {
    const data = useAtomValue(expensiveDataAtom);

    // 重い処理のシミュレーション
    const processedData = useMemo(() => {
      return data.map((item) => ({
        ...item,
        processed: performExpensiveCalculation(item),
      }));
    }, [data]);

    return (
      <div>
        {processedData.map((item) => (
          <div key={item.id}>{item.processed}</div>
        ))}
      </div>
    );
  }
);

メモリ効率

Atom のライフサイクル管理とメモリ使用量の最適化について解説します。

typescript// Atomのクリーンアップ
const temporaryDataAtom = atom<string | null>(null);

// 一定時間後に自動クリア
const autoCleanupAtom = atom(
  (get) => get(temporaryDataAtom),
  (get, set, value: string) => {
    set(temporaryDataAtom, value);

    // 5秒後に自動クリア
    setTimeout(() => {
      set(temporaryDataAtom, null);
    }, 5000);
  }
);

大量のデータを扱う際の最適化手法です。

typescript// ページネーション対応
const currentPageAtom = atom(1);
const itemsPerPageAtom = atom(10);
const allItemsAtom = atom<Item[]>([]);

// 現在のページのアイテムのみを取得
const currentPageItemsAtom = atom((get) => {
  const allItems = get(allItemsAtom);
  const currentPage = get(currentPageAtom);
  const itemsPerPage = get(itemsPerPageAtom);

  const startIndex = (currentPage - 1) * itemsPerPage;
  const endIndex = startIndex + itemsPerPage;

  return allItems.slice(startIndex, endIndex);
});

// 仮想化対応のAtom
const visibleRangeAtom = atom({ start: 0, end: 50 });

const visibleItemsAtom = atom((get) => {
  const allItems = get(allItemsAtom);
  const range = get(visibleRangeAtom);

  return allItems.slice(range.start, range.end);
});

まとめ

Jotai は、原子的なアプローチを採用することで、React アプリケーションの状態管理を劇的にシンプルかつ効率的にしてくれます。

この記事で解説した内容をまとめますと:

Jotai の主要な特徴

特徴詳細メリット
基本 Atom最小単位の状態管理シンプルで理解しやすい
派生 Atom他の Atom から計算される値効率的な再計算
非同期 AtomPromise ベースの状態管理統一的な非同期処理

パフォーマンス最適化のポイント

  • 適切な粒度での Atom 分割: 必要以上に大きな Atom は避ける
  • 選択的な購読: useAtomValue で必要な値のみを購読
  • メモ化の活用: React.memo や useMemo との組み合わせ
  • ライフサイクル管理: 不要になった Atom の適切なクリーンアップ

Jotai を使用することで、従来の状態管理ライブラリで感じていた複雑さやボイラープレートコードから解放され、より直感的で保守性の高いコードを書けるようになります。

小さな Atom から始めて、徐々に派生や非同期処理を組み合わせていくことで、複雑なアプリケーションでも見通しの良い状態管理を実現できるでしょう。

関連リンク