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 から計算される値 | 効率的な再計算 |
非同期 Atom | Promise ベースの状態管理 | 統一的な非同期処理 |
パフォーマンス最適化のポイント
- 適切な粒度での Atom 分割: 必要以上に大きな Atom は避ける
- 選択的な購読: useAtomValue で必要な値のみを購読
- メモ化の活用: React.memo や useMemo との組み合わせ
- ライフサイクル管理: 不要になった Atom の適切なクリーンアップ
Jotai を使用することで、従来の状態管理ライブラリで感じていた複雑さやボイラープレートコードから解放され、より直感的で保守性の高いコードを書けるようになります。
小さな Atom から始めて、徐々に派生や非同期処理を組み合わせていくことで、複雑なアプリケーションでも見通しの良い状態管理を実現できるでしょう。
関連リンク
- article
Jotai 全体像を一枚で理解:Atom・派生・非同期の関係を図解
- article
状態遷移を明文化する:XState × Jotai の堅牢な非同期フロー設計
- article
Undo/Redo を備えた履歴つき状態管理を Jotai で設計する
- article
BroadcastChannel でタブ間同期:Jotai 状態をリアルタイムで共有する
- article
jotai IndexedDB・localForage と連携した大容量永続化パターン(atom を超えて)
- article
jotai/optics で深いネストを安全に操作する実践ガイド
- article
Jotai 全体像を一枚で理解:Atom・派生・非同期の関係を図解
- article
Vue.js スクリプトセットアップ完全理解:`<script setup>` とコンパイルマクロの実力
- article
Tailwind CSS のユーティリティ設計を図で直感理解:原子化・コンポジション・トークンの関係
- article
Jest アーキテクチャ超図解:ランナー・トランスフォーマ・環境・レポーターの関係を一望
- article
【対処法】Chat GPT で発生する「Something went wrong while generating the response.」エラーの原因と対応
- article
Svelte 5 Runes 徹底解説:リアクティブの再設計と移行の勘所
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来