はじめに

Reactアプリケーションの状態管理は、プロジェクトの規模が大きくなるほど複雑になります。Context APIではProvider地獄や不要な再レンダリングの問題が発生し、Reduxではボイラープレートコードが増大します。こうした課題を解決するために生まれたのが、アトミックな状態管理ライブラリ「Jotai」です。

Jotaiは「状態」を意味する日本語で、ReactのuseStateを置き換える感覚で使えるシンプルなAPIを提供します。最小限のコアAPI(わずか2KB)でありながら、派生atom、非同期atom、atomFamilyなど柔軟な状態管理パターンをサポートしています。

本記事では、Jotaiの基本概念から実践的な活用パターンまでを解説します。

本記事を読むことで、以下のことができるようになります。

  • Jotaiの基本概念とアトミック状態管理の理解
  • atom定義とuseAtomによる状態管理の実装
  • 派生atomを使った計算状態の作成
  • atomFamilyによるパラメータ付きatom管理
  • 非同期atom(async atom)によるデータフェッチ
  • ZustandとJotaiの使い分け判断

実行環境・前提条件

必要な環境

  • Node.js 20.x以上
  • React 18以降
  • TypeScript 5.x
  • Viteで作成したReactプロジェクト(推奨)
  • VS Code(推奨)

前提知識

  • Reactの基本的なHooks(useState、useEffect)
  • TypeScriptの基本構文
  • 状態管理の基本概念

Jotaiの概要と特徴

アトミックな状態管理とは

Jotaiはボトムアップ型のアトミックな状態管理アプローチを採用しています。状態を「atom(原子)」という最小単位で定義し、それらを組み合わせて複雑な状態を構築します。

flowchart TB
    subgraph "アトミック状態管理(Jotai)"
        A1[countAtom] --> D1[doubleCountAtom]
        A2[userAtom] --> D2[userNameAtom]
        A1 --> D3[totalAtom]
        A2 --> D3
    end
    
    subgraph "トップダウン状態管理(Redux/Zustand)"
        S[Store]
        S --> S1[count]
        S --> S2[user]
        S --> S3[settings]
    end

この設計により、以下のメリットが得られます。

特徴 説明
最小限の再レンダリング atomを購読しているコンポーネントのみが再レンダリング
コード分割に最適 atomは独立しているため、必要な部分だけをインポート可能
シンプルなAPI useStateと同様の直感的なインターフェース
Suspense対応 非同期atomがReact Suspenseとネイティブに統合
TypeScript親和性 型推論が効き、型安全な状態管理を実現

Jotaiの主要機能

Jotaiのコアは非常にシンプルで、主に以下の4つのAPIで構成されています。

  • atom: 状態の最小単位を定義する関数
  • useAtom: atomの値を読み書きするHook
  • Provider: atomのスコープを分離するコンポーネント(省略可能)
  • Store: React外部からatomを操作するためのAPI

セットアップ手順

パッケージのインストール

Viteで作成したReact + TypeScriptプロジェクトにJotaiをインストールします。

1
npm install jotai

Jotaiはゼロ設定で動作するため、追加の設定ファイルは不要です。ただし、開発体験を向上させるために、DevToolsの導入を推奨します。

1
npm install jotai-devtools

DevToolsの設定(オプション)

開発時にatomの状態を視覚的に確認するため、DevToolsを設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { DevTools } from 'jotai-devtools'
import 'jotai-devtools/styles.css'
import App from './App'
import './index.css'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <DevTools />
    <App />
  </React.StrictMode>,
)

基本的なatom定義とuseAtom実装

プリミティブatomの作成

最も基本的なatomは、初期値を渡すだけで作成できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// src/atoms/counterAtom.ts
import { atom } from 'jotai'

// プリミティブatom - 数値
export const countAtom = atom(0)

// プリミティブatom - 文字列
export const messageAtom = atom('Hello, Jotai!')

// プリミティブatom - オブジェクト
export const userAtom = atom({
  id: 1,
  name: '田中太郎',
  email: 'tanaka@example.com',
})

useAtomによる状態の読み書き

atomの値を読み書きするにはuseAtomフックを使用します。useStateと同様のインターフェースで、値と更新関数のタプルを返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// src/components/Counter.tsx
import { useAtom } from 'jotai'
import { countAtom } from '../atoms/counterAtom'

export const Counter = () => {
  const [count, setCount] = useAtom(countAtom)

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={() => setCount((prev) => prev + 1)}>
        増加
      </button>
      <button onClick={() => setCount((prev) => prev - 1)}>
        減少
      </button>
      <button onClick={() => setCount(0)}>リセット</button>
    </div>
  )
}

読み取り専用・書き込み専用のフック

パフォーマンス最適化のため、値の読み取りのみ、または書き込みのみを行うフックも提供されています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import { useAtomValue, useSetAtom } from 'jotai'
import { countAtom } from '../atoms/counterAtom'

// 読み取り専用 - 値が変わっても更新関数は不要な場合
export const CountDisplay = () => {
  const count = useAtomValue(countAtom)
  return <span>現在のカウント: {count}</span>
}

// 書き込み専用 - 値を表示せず更新のみ行う場合
export const CountController = () => {
  const setCount = useSetAtom(countAtom)
  return (
    <button onClick={() => setCount((prev) => prev + 1)}>
      カウントを増やす
    </button>
  )
}

useSetAtomを使用した場合、atomの値が変更されてもコンポーネントは再レンダリングされません。これは、ボタンやフォーム送信など、状態を更新するだけのコンポーネントで有効です。

派生atomの使い方

読み取り専用の派生atom

派生atomは、他のatomの値から計算される状態を定義します。読み取り関数を渡すことで作成できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// src/atoms/derivedAtoms.ts
import { atom } from 'jotai'
import { countAtom } from './counterAtom'

// countAtomから派生した読み取り専用atom
export const doubleCountAtom = atom((get) => get(countAtom) * 2)

// 複数のatomから派生
export const priceAtom = atom(1000)
export const quantityAtom = atom(1)
export const taxRateAtom = atom(0.1)

export const totalPriceAtom = atom((get) => {
  const price = get(priceAtom)
  const quantity = get(quantityAtom)
  const taxRate = get(taxRateAtom)
  const subtotal = price * quantity
  return Math.floor(subtotal * (1 + taxRate))
})

派生atomは依存するatomの値が変更されたときのみ再計算されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// src/components/PriceCalculator.tsx
import { useAtom, useAtomValue } from 'jotai'
import { 
  priceAtom, 
  quantityAtom, 
  totalPriceAtom 
} from '../atoms/derivedAtoms'

export const PriceCalculator = () => {
  const [price, setPrice] = useAtom(priceAtom)
  const [quantity, setQuantity] = useAtom(quantityAtom)
  const totalPrice = useAtomValue(totalPriceAtom)

  return (
    <div>
      <div>
        <label>
          単価:
          <input
            type="number"
            value={price}
            onChange={(e) => setPrice(Number(e.target.value))}
          />
        </label>
      </div>
      <div>
        <label>
          数量:
          <input
            type="number"
            value={quantity}
            onChange={(e) => setQuantity(Number(e.target.value))}
          />
        </label>
      </div>
      <p>合計金額(税込): {totalPrice.toLocaleString()}</p>
    </div>
  )
}

読み書き可能な派生atom

派生atomに書き込み関数を追加することで、読み書き可能なatomを作成できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// src/atoms/temperatureAtom.ts
import { atom } from 'jotai'

// 基準となる摂氏温度
export const celsiusAtom = atom(25)

// 華氏への変換(読み書き可能な派生atom)
export const fahrenheitAtom = atom(
  // read: 摂氏から華氏へ変換
  (get) => get(celsiusAtom) * (9 / 5) + 32,
  // write: 華氏から摂氏へ変換して保存
  (get, set, newFahrenheit: number) => {
    const celsius = (newFahrenheit - 32) * (5 / 9)
    set(celsiusAtom, celsius)
  }
)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// src/components/TemperatureConverter.tsx
import { useAtom } from 'jotai'
import { celsiusAtom, fahrenheitAtom } from '../atoms/temperatureAtom'

export const TemperatureConverter = () => {
  const [celsius, setCelsius] = useAtom(celsiusAtom)
  const [fahrenheit, setFahrenheit] = useAtom(fahrenheitAtom)

  return (
    <div>
      <div>
        <label>
          摂氏:
          <input
            type="number"
            value={celsius.toFixed(1)}
            onChange={(e) => setCelsius(Number(e.target.value))}
          />
          °C
        </label>
      </div>
      <div>
        <label>
          華氏:
          <input
            type="number"
            value={fahrenheit.toFixed(1)}
            onChange={(e) => setFahrenheit(Number(e.target.value))}
          />
          °F
        </label>
      </div>
    </div>
  )
}

どちらの入力フィールドを変更しても、両方の値が同期されます。

atomFamilyの使い方

atomFamilyとは

atomFamilyは、パラメータに基づいて動的にatomを生成するユーティリティです。TODOリストの各アイテムや、ユーザーIDごとのデータなど、同じ構造を持つ複数のatomを効率的に管理できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/atoms/todoAtoms.ts
import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'

// TODOアイテムの型定義
interface TodoItem {
  id: string
  text: string
  completed: boolean
}

// TODOのIDリストを管理するatom
export const todoIdsAtom = atom<string[]>([])

// 各TODOアイテムを管理するatomFamily
export const todoAtomFamily = atomFamily((id: string) =>
  atom<TodoItem>({
    id,
    text: '',
    completed: false,
  })
)

注意: atomFamilyはJotai v3で非推奨となり、jotai-familyパッケージへの移行が推奨されています。新規プロジェクトでは以下のようにインストールしてください。

1
npm install jotai-family
1
2
// jotai-familyを使用する場合
import { atomFamily } from 'jotai-family'

atomFamilyの実装例

TODOリストを例に、atomFamilyの使い方を見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// src/atoms/todoAtoms.ts
import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'

interface TodoItem {
  id: string
  text: string
  completed: boolean
}

export const todoIdsAtom = atom<string[]>([])

export const todoAtomFamily = atomFamily((id: string) =>
  atom<TodoItem>({
    id,
    text: '',
    completed: false,
  })
)

// 新しいTODOを追加するwrite-only atom
export const addTodoAtom = atom(null, (get, set, text: string) => {
  const id = crypto.randomUUID()
  const ids = get(todoIdsAtom)
  
  // IDリストに追加
  set(todoIdsAtom, [...ids, id])
  
  // 新しいTODOアイテムを設定
  set(todoAtomFamily(id), {
    id,
    text,
    completed: false,
  })
})

// TODOを削除するwrite-only atom
export const removeTodoAtom = atom(null, (get, set, id: string) => {
  const ids = get(todoIdsAtom)
  set(todoIdsAtom, ids.filter((todoId) => todoId !== id))
  
  // atomFamilyからも削除(メモリリーク防止)
  todoAtomFamily.remove(id)
})
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// src/components/TodoList.tsx
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useState } from 'react'
import {
  todoIdsAtom,
  todoAtomFamily,
  addTodoAtom,
  removeTodoAtom,
} from '../atoms/todoAtoms'

const TodoItem = ({ id }: { id: string }) => {
  const [todo, setTodo] = useAtom(todoAtomFamily(id))
  const removeTodo = useSetAtom(removeTodoAtom)

  return (
    <li>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={(e) =>
          setTodo((prev) => ({ ...prev, completed: e.target.checked }))
        }
      />
      <span
        style={{
          textDecoration: todo.completed ? 'line-through' : 'none',
        }}
      >
        {todo.text}
      </span>
      <button onClick={() => removeTodo(id)}>削除</button>
    </li>
  )
}

export const TodoList = () => {
  const todoIds = useAtomValue(todoIdsAtom)
  const addTodo = useSetAtom(addTodoAtom)
  const [inputText, setInputText] = useState('')

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (inputText.trim()) {
      addTodo(inputText.trim())
      setInputText('')
    }
  }

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={inputText}
          onChange={(e) => setInputText(e.target.value)}
          placeholder="新しいTODOを入力"
        />
        <button type="submit">追加</button>
      </form>
      <ul>
        {todoIds.map((id) => (
          <TodoItem key={id} id={id} />
        ))}
      </ul>
    </div>
  )
}

atomFamilyを使用する際は、不要になったatomをremoveメソッドで削除し、メモリリークを防ぐことが重要です。

非同期状態管理(async atom)

非同期atomの基本

Jotaiは非同期処理をネイティブにサポートしています。読み取り関数でPromiseを返すと、自動的に非同期atomとなります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/atoms/userAtoms.ts
import { atom } from 'jotai'

interface User {
  id: number
  name: string
  email: string
}

// ユーザーIDを管理するatom
export const userIdAtom = atom(1)

// ユーザー情報を取得する非同期atom
export const userAtom = atom(async (get) => {
  const userId = get(userIdAtom)
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  )
  
  if (!response.ok) {
    throw new Error('ユーザー情報の取得に失敗しました')
  }
  
  return response.json() as Promise<User>
})

Suspenseとの統合

非同期atomはReact Suspenseと統合されており、ローディング状態を宣言的に処理できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// src/components/UserProfile.tsx
import { Suspense } from 'react'
import { useAtom, useAtomValue } from 'jotai'
import { userIdAtom, userAtom } from '../atoms/userAtoms'

const UserInfo = () => {
  const user = useAtomValue(userAtom)

  return (
    <div>
      <h3>{user.name}</h3>
      <p>Email: {user.email}</p>
    </div>
  )
}

const UserSelector = () => {
  const [userId, setUserId] = useAtom(userIdAtom)

  return (
    <div>
      <span>ユーザーID: {userId}</span>
      <button onClick={() => setUserId((prev) => Math.max(1, prev - 1))}>
        前へ
      </button>
      <button onClick={() => setUserId((prev) => prev + 1)}>
        次へ
      </button>
    </div>
  )
}

export const UserProfile = () => {
  return (
    <div>
      <UserSelector />
      <Suspense fallback={<div>読み込み中...</div>}>
        <UserInfo />
      </Suspense>
    </div>
  )
}

AbortControllerによるリクエストのキャンセル

Jotaiの非同期atomはAbortControllerをサポートしており、新しいリクエストが発生した際に古いリクエストを自動的にキャンセルできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/atoms/searchAtoms.ts
import { atom } from 'jotai'

interface SearchResult {
  id: number
  title: string
}

export const searchQueryAtom = atom('')

export const searchResultsAtom = atom(async (get, { signal }) => {
  const query = get(searchQueryAtom)
  
  if (!query.trim()) {
    return []
  }
  
  // デバウンス効果のための遅延
  await new Promise((resolve) => setTimeout(resolve, 300))
  
  // signalを渡してキャンセル可能にする
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts?q=${encodeURIComponent(query)}`,
    { signal }
  )
  
  if (!response.ok) {
    throw new Error('検索に失敗しました')
  }
  
  return response.json() as Promise<SearchResult[]>
})

signalが渡されることで、ユーザーが連続して入力した場合、古いリクエストは自動的にキャンセルされます。

書き込み可能な非同期atom

データの更新を伴う非同期処理は、書き込み関数で実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// src/atoms/postAtoms.ts
import { atom } from 'jotai'

interface Post {
  id: number
  title: string
  body: string
}

// 投稿リストを管理するatom
export const postsAtom = atom<Post[]>([])

// 投稿を取得する非同期atom
export const fetchPostsAtom = atom(
  (get) => get(postsAtom),
  async (get, set) => {
    const response = await fetch(
      'https://jsonplaceholder.typicode.com/posts?_limit=10'
    )
    const posts = await response.json()
    set(postsAtom, posts)
  }
)

// 新しい投稿を作成するatom
export const createPostAtom = atom(
  null,
  async (get, set, newPost: Omit<Post, 'id'>) => {
    const response = await fetch(
      'https://jsonplaceholder.typicode.com/posts',
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newPost),
      }
    )
    const createdPost = await response.json()
    
    // 既存のリストに追加
    const currentPosts = get(postsAtom)
    set(postsAtom, [createdPost, ...currentPosts])
    
    return createdPost
  }
)

ZustandとJotaiの違い・使い分け

設計思想の違い

JotaiとZustandは同じPoimandres組織で開発されていますが、設計思想が大きく異なります。

項目 Jotai Zustand
状態構造 ボトムアップ(atom単位) トップダウン(単一Store)
設計パターン Recoilライク Reduxライク
主なユースケース useState + useContextの置き換え モジュールレベルの状態管理
Suspense対応 ネイティブサポート 別途設定が必要
DevTools jotai-devtools Redux DevTools
バンドルサイズ 約2KB 約1KB
flowchart LR
    subgraph "Jotai(ボトムアップ)"
        A1[atom A] --> C1[Component 1]
        A2[atom B] --> C2[Component 2]
        A1 --> A3[derived atom]
        A2 --> A3
        A3 --> C3[Component 3]
    end
    
    subgraph "Zustand(トップダウン)"
        S[Store] --> C4[Component 1]
        S --> C5[Component 2]
        S --> C6[Component 3]
    end

使い分けの指針

以下の基準で選択することを推奨します。

Jotaiが適しているケース

  • useState + useContextのシンプルな置き換えが必要
  • コンポーネント単位での細かい再レンダリング最適化が重要
  • React Suspenseを活用した非同期処理を実装したい
  • 状態をコード分割して必要な部分だけインポートしたい
  • 状態間の依存関係(派生状態)が複雑

Zustandが適しているケース

  • アプリケーション全体で単一のStoreを管理したい
  • Redux DevToolsを使ったデバッグ体験を重視
  • React外部(Vanilla JSなど)からも状態にアクセスしたい
  • シンプルなモジュールレベルの状態管理で十分
  • チームがReduxパターンに慣れている

併用パターン

JotaiとZustandは競合ではなく、併用することも可能です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// グローバルな設定はZustand
import { create } from 'zustand'

interface AppStore {
  theme: 'light' | 'dark'
  setTheme: (theme: 'light' | 'dark') => void
}

export const useAppStore = create<AppStore>((set) => ({
  theme: 'light',
  setTheme: (theme) => set({ theme }),
}))

// コンポーネントローカルな状態はJotai
import { atom } from 'jotai'

export const formDataAtom = atom({
  name: '',
  email: '',
})

実務での活用パターン

パターン1: フォーム状態管理

フォームの各フィールドをatomで管理し、バリデーション状態を派生atomで計算します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// src/atoms/formAtoms.ts
import { atom } from 'jotai'

// フォームフィールド
export const nameAtom = atom('')
export const emailAtom = atom('')
export const passwordAtom = atom('')

// バリデーション状態(派生atom)
export const formValidationAtom = atom((get) => {
  const name = get(nameAtom)
  const email = get(emailAtom)
  const password = get(passwordAtom)

  const errors: Record<string, string> = {}

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

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

  if (password.length < 8) {
    errors.password = 'パスワードは8文字以上で入力してください'
  }

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

// フォームリセット
export const resetFormAtom = atom(null, (get, set) => {
  set(nameAtom, '')
  set(emailAtom, '')
  set(passwordAtom, '')
})

パターン2: 認証状態管理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// src/atoms/authAtoms.ts
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'

interface User {
  id: string
  name: string
  email: string
}

// localStorageに永続化されるatom
export const authTokenAtom = atomWithStorage<string | null>(
  'auth_token',
  null
)

// ユーザー情報(トークンから派生)
export const currentUserAtom = atom(async (get) => {
  const token = get(authTokenAtom)
  
  if (!token) {
    return null
  }

  const response = await fetch('/api/me', {
    headers: { Authorization: `Bearer ${token}` },
  })

  if (!response.ok) {
    return null
  }

  return response.json() as Promise<User>
})

// 認証状態
export const isAuthenticatedAtom = atom((get) => {
  const token = get(authTokenAtom)
  return token !== null
})

// ログアウト処理
export const logoutAtom = atom(null, (get, set) => {
  set(authTokenAtom, null)
})

パターン3: ページネーション付きリスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// src/atoms/paginatedListAtoms.ts
import { atom } from 'jotai'

interface PaginatedResponse<T> {
  data: T[]
  total: number
  page: number
  perPage: number
}

interface Product {
  id: number
  name: string
  price: number
}

export const currentPageAtom = atom(1)
export const perPageAtom = atom(10)

export const productsAtom = atom(async (get, { signal }) => {
  const page = get(currentPageAtom)
  const perPage = get(perPageAtom)

  const response = await fetch(
    `/api/products?page=${page}&per_page=${perPage}`,
    { signal }
  )

  return response.json() as Promise<PaginatedResponse<Product>>
})

// ページ操作
export const goToPageAtom = atom(null, (get, set, page: number) => {
  set(currentPageAtom, page)
})

export const nextPageAtom = atom(null, (get, set) => {
  set(currentPageAtom, (prev) => prev + 1)
})

export const prevPageAtom = atom(null, (get, set) => {
  set(currentPageAtom, (prev) => Math.max(1, prev - 1))
})

期待される結果

本記事で解説した内容を実装することで、以下の結果が得られます。

開発効率の向上

  • useStateと同様の直感的なAPIにより、学習コストを最小限に抑えられます
  • atomを分離して定義することで、状態管理のコードが整理され、可読性が向上します
  • TypeScriptとの親和性が高く、型安全な状態管理を実現できます

パフォーマンスの改善

  • atomを購読しているコンポーネントのみが再レンダリングされるため、不要な再レンダリングを削減できます
  • 派生atomは依存するatomが変更されたときのみ再計算されます
  • コード分割により、必要な状態のみをバンドルに含められます

保守性の向上

  • 状態がatom単位で分離されているため、影響範囲を把握しやすくなります
  • 派生atomにより、計算ロジックをコンポーネントから分離できます
  • DevToolsを使用して、状態の変化を視覚的にデバッグできます

まとめ

Jotaiは、Reactのアトミック状態管理を実現する軽量で柔軟なライブラリです。useStateに似た直感的なAPIでありながら、派生atom、非同期atom、atomFamilyなど強力な機能を提供します。

Zustandがトップダウンの単一Store設計であるのに対し、Jotaiはボトムアップのatom単位設計を採用しています。プロジェクトの特性やチームの習熟度に応じて、適切なライブラリを選択してください。

次のステップとして、atomWithStorageによるlocalStorage永続化や、jotai-effectによる副作用管理など、Jotaiのユーティリティ機能を探索することを推奨します。

参考リンク