React 初心者必見!Jotai で学ぶ現代的な状態管理の基礎知識

React を学び始めた皆さん、こんにちは!useState でシンプルな状態管理は覚えたけれど、コンポーネントが増えるとどう管理すればいいかわからない...そんな悩みを抱えていませんか?
「props をいくつものコンポーネントに渡すのが面倒」「Context API は難しそう」「Redux は複雑すぎる」といった声をよく聞きます。そんな React 初心者の方に朗報です!今回は、学習しやすく実用的な状態管理ライブラリ「Jotai」を、段階的に学んでいきましょう。
本記事では、状態管理の基本概念から始まり、実際に手を動かしながら 3 つのミニアプリケーションを作成します。難しい理論よりも「まずやってみる」ことを重視し、初心者の方でも安心して取り組めるよう構成しました。記事を読み終える頃には、現代的な状態管理の基礎がしっかりと身についているはずです。
React 初心者が混乱する状態管理の世界
useState、useContext、Redux...何を選ぶべき?
React を学び始めると、状態管理の選択肢の多さに圧倒されることがあります。それぞれの特徴を整理してみましょう。
useStateは最もシンプルな状態管理方法です。1 つのコンポーネント内で使用する分には問題ありませんが、複数のコンポーネント間で状態を共有したい場合に課題が生じます。
typescript// useStateの基本的な使い方
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>
増加
</button>
</div>
);
}
useContextは、コンポーネントツリー全体で状態を共有できる便利な機能です。しかし、設定が複雑で、パフォーマンスの問題も発生しやすいという特徴があります。
typescript// useContextの例(設定が複雑)
const UserContext = createContext();
function App() {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
<Header />
<Main />
</UserContext.Provider>
);
}
function Header() {
const { user } = useContext(UserContext);
return <div>こんにちは、{user?.name}さん</div>;
}
Reduxは強力ですが、初心者には学習コストが高すぎるのが現実です。action、reducer、store など覚えることが多く、シンプルな状態管理にも大量のコードが必要になります。
各手法のメリット・デメリット比較
状態管理手法を比較表で整理すると、以下のようになります。
手法 | 学習コスト | 設定の複雑さ | 適用範囲 | 初心者推奨度 |
---|---|---|---|---|
useState | 低 | 簡単 | 単一コンポーネント | ⭐⭐⭐⭐⭐ |
useContext | 中 | 中程度 | アプリ全体 | ⭐⭐⭐ |
Redux | 高 | 複雑 | 大規模アプリ | ⭐ |
Jotai | 低 | 簡単 | 柔軟 | ⭐⭐⭐⭐⭐ |
この表を見ると、Jotai が初心者にとって最もバランスの良い選択肢であることがわかります。
初心者にとって最適な学習順序
React 状態管理の学習順序として、以下をおすすめします:
-
useState をマスターする(1-2 週間)
- 基本的な状態の更新
- 配列やオブジェクトの更新パターン
-
Jotai で状態共有を学ぶ(2-3 週間)
- atom の基本概念
- コンポーネント間での状態共有
- 実践的なアプリケーション作成
-
必要に応じて他の手法を学ぶ
- より大規模なアプリでは Redux
- 特定のユースケースでは useContext
この順序で学習することで、段階的にスキルアップできます。
Jotai が初心者フレンドリーな理由
学習コストの低さ
Jotai の最大の魅力は、その学習コストの低さです。useState を理解していれば、すぐに使い始めることができます。
typescript// useStateと似た書き方
const [count, setCount] = useState(0);
// Jotaiも同じような感覚で使える
const [count, setCount] = useAtom(countAtom);
新しく覚える概念は「atom」だけです。atom は「状態の入れ物」と考えれば理解しやすいでしょう。
typescript// atomの定義はとてもシンプル
const countAtom = atom(0);
const nameAtom = atom('');
const isVisibleAtom = atom(true);
直感的な API 設計
Jotai の API は、React の標準的な書き方に非常に近く設計されています。そのため、既存の React の知識をそのまま活用できるのです。
typescript// React標準のパターン
function Component() {
const [state, setState] = useState(initialValue);
return (
<button onClick={() => setState(newValue)}>
{state}
</button>
);
}
// Jotaiのパターン(ほぼ同じ!)
function Component() {
const [state, setState] = useAtom(myAtom);
return (
<button onClick={() => setState(newValue)}>
{state}
</button>
);
}
この一貫性により、学習時の混乱を最小限に抑えることができます。
エラーメッセージの親切さ
Jotai は、初心者がつまずきやすいポイントで親切なエラーメッセージを表示します。TypeScript を使用している場合、型エラーも非常にわかりやすく表示されるため、問題の特定と解決が容易です。
typescript// 型安全性により、間違いを事前に発見できる
const numberAtom = atom(0);
const [count, setCount] = useAtom(numberAtom);
// これはTypeScriptが警告してくれる
setCount('文字列'); // エラー: string型をnumber型に代入できません
また、React DevTools との連携も優れており、状態の変化を視覚的に確認できるため、デバッグも簡単に行えます。
基礎から始める Jotai 実践ガイド
開発環境の準備(VSCode 設定含む)
まずは、快適に開発できる環境を整えましょう。以下の手順で進めていきます。
1. プロジェクトの作成
bash# 新しいReactプロジェクトを作成
yarn create react-app my-jotai-app --template typescript
cd my-jotai-app
# Jotaiをインストール
yarn add jotai
# 開発ツールもインストール(任意)
yarn add -D jotai-devtools
2. VSCode の拡張機能を導入
以下の拡張機能をインストールすることをおすすめします:
拡張機能名 | 用途 | 重要度 |
---|---|---|
ES7+ React/Redux/React-Native snippets | React コード補完 | 必須 |
TypeScript Importer | 自動インポート | 推奨 |
Auto Rename Tag | タグ自動リネーム | 推奨 |
Bracket Pair Colorizer | 括弧の色分け | 推奨 |
3. VSCode の設定(settings.json)
json{
"typescript.preferences.importModuleSpecifier": "relative",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true
},
"emmet.includeLanguages": {
"typescript": "html",
"typescriptreact": "html"
}
}
最初のプロジェクト作成
環境準備ができたら、最初の Jotai アプリケーションを作成してみましょう。
atoms.ts ファイルの作成
typescript// src/atoms.ts
import { atom } from 'jotai';
// カウンターのatom
export const countAtom = atom(0);
// ユーザー名のatom
export const userNameAtom = atom('ゲスト');
// 表示モードのatom
export const isDarkModeAtom = atom(false);
App.tsx の更新
typescript// src/App.tsx
import React from 'react';
import { useAtom } from 'jotai';
import {
countAtom,
userNameAtom,
isDarkModeAtom,
} from './atoms';
function App() {
const [count, setCount] = useAtom(countAtom);
const [userName, setUserName] = useAtom(userNameAtom);
const [isDarkMode, setIsDarkMode] =
useAtom(isDarkModeAtom);
return (
<div
style={{
backgroundColor: isDarkMode ? '#333' : '#fff',
color: isDarkMode ? '#fff' : '#333',
padding: '20px',
minHeight: '100vh',
}}
>
<h1>はじめての Jotai アプリ</h1>
{/* ユーザー名表示・編集 */}
<div>
<label>
お名前:
<input
value={userName}
onChange={(e) => setUserName(e.target.value)}
/>
</label>
<p>こんにちは、{userName}さん!</p>
</div>
{/* カウンター */}
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>
+1
</button>
<button onClick={() => setCount(count - 1)}>
-1
</button>
<button onClick={() => setCount(0)}>
リセット
</button>
</div>
{/* ダークモード切り替え */}
<div>
<label>
<input
type='checkbox'
checked={isDarkMode}
onChange={(e) =>
setIsDarkMode(e.target.checked)
}
/>
ダークモード
</label>
</div>
</div>
);
}
export default App;
デバッグツールの使い方
Jotai の状態を効果的にデバッグするための方法を学びましょう。
React DevTools の活用
- Chrome に React Developer Tools 拡張機能をインストール
- 開発者ツールで Components タブを確認
- Jotai を使用しているコンポーネントの状態変化を監視
jotai-devtools の導入
typescript// src/App.tsx(デバッグ版)
import React from 'react';
import { useAtom } from 'jotai';
import { useAtomDevtools } from 'jotai-devtools';
import { countAtom, userNameAtom } from './atoms';
function App() {
const [count, setCount] = useAtom(countAtom);
const [userName, setUserName] = useAtom(userNameAtom);
// デバッグ情報を追加
useAtomDevtools(countAtom, 'count');
useAtomDevtools(userNameAtom, 'userName');
// 以下、前のコードと同じ
return (
// ...
);
}
console.log でのデバッグ
typescript// 状態変化を監視するためのデバッグコード
function App() {
const [count, setCount] = useAtom(countAtom);
// 状態が変わるたびにログ出力
useEffect(() => {
console.log('カウントが変更されました:', count);
}, [count]);
return (
// ...
);
}
これで基礎的な環境構築とデバッグ方法が整いました。次のセクションでは、実際にアプリケーションを作成しながら学習を進めていきます。
実際に作って学ぶ:3 つのミニアプリ
実際に手を動かしながら学習することで、Jotai の理解が深まります。3 つの異なるタイプのアプリケーションを通じて、様々な状態管理パターンを体験しましょう。
プロジェクト 1:Todo 管理アプリ
最初は、定番の Todo アプリから始めましょう。リスト操作と状態管理の基本が学べます。
atoms の定義
typescript// src/atoms/todoAtoms.ts
import { atom } from 'jotai';
export interface Todo {
id: number;
text: string;
completed: boolean;
createdAt: Date;
}
// Todoリストのatom
export const todosAtom = atom<Todo[]>([]);
// 新しいTodoのテキスト入力用atom
export const newTodoTextAtom = atom('');
// フィルター用atom
export const filterAtom = atom<
'all' | 'completed' | 'active'
>('all');
// 派生atom:フィルター済みのTodoリスト
export const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom);
const filter = get(filterAtom);
switch (filter) {
case 'completed':
return todos.filter((todo) => todo.completed);
case 'active':
return todos.filter((todo) => !todo.completed);
default:
return todos;
}
});
// 派生atom:統計情報
export const todoStatsAtom = atom((get) => {
const todos = get(todosAtom);
const total = todos.length;
const completed = todos.filter(
(todo) => todo.completed
).length;
const active = total - completed;
return { total, completed, active };
});
TodoApp コンポーネント
typescript// src/components/TodoApp.tsx
import React from 'react';
import { useAtom } from 'jotai';
import {
todosAtom,
newTodoTextAtom,
filterAtom,
filteredTodosAtom,
todoStatsAtom,
Todo,
} from '../atoms/todoAtoms';
export function TodoApp() {
const [todos, setTodos] = useAtom(todosAtom);
const [newTodoText, setNewTodoText] =
useAtom(newTodoTextAtom);
const [filter, setFilter] = useAtom(filterAtom);
const [filteredTodos] = useAtom(filteredTodosAtom);
const [stats] = useAtom(todoStatsAtom);
const addTodo = () => {
if (newTodoText.trim()) {
const newTodo: Todo = {
id: Date.now(),
text: newTodoText.trim(),
completed: false,
createdAt: new Date(),
};
setTodos([...todos, newTodo]);
setNewTodoText('');
}
};
const toggleTodo = (id: number) => {
setTodos(
todos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
};
const deleteTodo = (id: number) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
return (
<div
style={{
maxWidth: '500px',
margin: '0 auto',
padding: '20px',
}}
>
<h2>Todo 管理アプリ</h2>
{/* 統計情報 */}
<div
style={{
marginBottom: '20px',
padding: '10px',
backgroundColor: '#f5f5f5',
}}
>
<p>
全体: {stats.total} | 完了: {stats.completed} |
未完了: {stats.active}
</p>
</div>
{/* 新しいTodo追加 */}
<div style={{ marginBottom: '20px' }}>
<input
type='text'
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
placeholder='新しいタスクを入力...'
style={{
padding: '8px',
marginRight: '10px',
width: '300px',
}}
/>
<button
onClick={addTodo}
style={{ padding: '8px 16px' }}
>
追加
</button>
</div>
{/* フィルター */}
<div style={{ marginBottom: '20px' }}>
{(['all', 'active', 'completed'] as const).map(
(filterType) => (
<button
key={filterType}
onClick={() => setFilter(filterType)}
style={{
marginRight: '10px',
padding: '5px 10px',
backgroundColor:
filter === filterType
? '#007bff'
: '#f8f9fa',
color:
filter === filterType ? 'white' : 'black',
border: '1px solid #dee2e6',
}}
>
{filterType === 'all'
? 'すべて'
: filterType === 'active'
? '未完了'
: '完了済み'}
</button>
)
)}
</div>
{/* Todoリスト */}
<div>
{filteredTodos.map((todo) => (
<div
key={todo.id}
style={{
display: 'flex',
alignItems: 'center',
padding: '10px',
borderBottom: '1px solid #eee',
}}
>
<input
type='checkbox'
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
style={{ marginRight: '10px' }}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed
? 'line-through'
: 'none',
opacity: todo.completed ? 0.6 : 1,
}}
>
{todo.text}
</span>
<button
onClick={() => deleteTodo(todo.id)}
style={{
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
padding: '5px 10px',
borderRadius: '3px',
}}
>
削除
</button>
</div>
))}
</div>
</div>
);
}
プロジェクト 2:ユーザー設定パネル
次は、フォーム管理と設定値の永続化を学べるユーザー設定パネルを作成します。
settings atoms の定義
typescript// src/atoms/settingsAtoms.ts
import { atom } from 'jotai';
export interface UserSettings {
name: string;
email: string;
theme: 'light' | 'dark';
notifications: {
email: boolean;
push: boolean;
sms: boolean;
};
language: 'ja' | 'en';
}
// デフォルト設定
const defaultSettings: UserSettings = {
name: '',
email: '',
theme: 'light',
notifications: {
email: true,
push: true,
sms: false,
},
language: 'ja',
};
// ローカルストレージから初期値を取得する関数
const getInitialSettings = (): UserSettings => {
try {
const saved = localStorage.getItem('userSettings');
return saved
? { ...defaultSettings, ...JSON.parse(saved) }
: defaultSettings;
} catch {
return defaultSettings;
}
};
// メイン設定atom
export const userSettingsAtom = atom<UserSettings>(
getInitialSettings()
);
// 設定を保存するatom(書き込み専用)
export const saveSettingsAtom = atom(
null,
(get, set, newSettings: UserSettings) => {
set(userSettingsAtom, newSettings);
localStorage.setItem(
'userSettings',
JSON.stringify(newSettings)
);
}
);
// 個別の設定項目atom(派生atom)
export const nameAtom = atom(
(get) => get(userSettingsAtom).name,
(get, set, newName: string) => {
const settings = get(userSettingsAtom);
set(saveSettingsAtom, { ...settings, name: newName });
}
);
export const emailAtom = atom(
(get) => get(userSettingsAtom).email,
(get, set, newEmail: string) => {
const settings = get(userSettingsAtom);
set(saveSettingsAtom, { ...settings, email: newEmail });
}
);
export const themeAtom = atom(
(get) => get(userSettingsAtom).theme,
(get, set, newTheme: 'light' | 'dark') => {
const settings = get(userSettingsAtom);
set(saveSettingsAtom, { ...settings, theme: newTheme });
}
);
SettingsPanel コンポーネント
typescript// src/components/SettingsPanel.tsx
import React from 'react';
import { useAtom } from 'jotai';
import {
nameAtom,
emailAtom,
themeAtom,
userSettingsAtom,
saveSettingsAtom,
} from '../atoms/settingsAtoms';
export function SettingsPanel() {
const [name, setName] = useAtom(nameAtom);
const [email, setEmail] = useAtom(emailAtom);
const [theme, setTheme] = useAtom(themeAtom);
const [settings] = useAtom(userSettingsAtom);
const [, saveSettings] = useAtom(saveSettingsAtom);
const updateNotifications = (
key: keyof typeof settings.notifications,
value: boolean
) => {
const newSettings = {
...settings,
notifications: {
...settings.notifications,
[key]: value,
},
};
saveSettings(newSettings);
};
const resetSettings = () => {
const defaultSettings = {
name: '',
email: '',
theme: 'light' as const,
notifications: {
email: true,
push: true,
sms: false,
},
language: 'ja' as const,
};
saveSettings(defaultSettings);
};
return (
<div
style={{
maxWidth: '600px',
margin: '0 auto',
padding: '20px',
backgroundColor: theme === 'dark' ? '#333' : '#fff',
color: theme === 'dark' ? '#fff' : '#333',
borderRadius: '8px',
}}
>
<h2>ユーザー設定</h2>
{/* 基本情報 */}
<section style={{ marginBottom: '30px' }}>
<h3>基本情報</h3>
<div style={{ marginBottom: '15px' }}>
<label
style={{
display: 'block',
marginBottom: '5px',
}}
>
名前:
</label>
<input
type='text'
value={name}
onChange={(e) => setName(e.target.value)}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid #ddd',
}}
/>
</div>
<div style={{ marginBottom: '15px' }}>
<label
style={{
display: 'block',
marginBottom: '5px',
}}
>
メールアドレス:
</label>
<input
type='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid #ddd',
}}
/>
</div>
</section>
{/* テーマ設定 */}
<section style={{ marginBottom: '30px' }}>
<h3>表示設定</h3>
<div>
<label style={{ marginRight: '20px' }}>
<input
type='radio'
value='light'
checked={theme === 'light'}
onChange={(e) =>
setTheme(e.target.value as 'light')
}
style={{ marginRight: '5px' }}
/>
ライトモード
</label>
<label>
<input
type='radio'
value='dark'
checked={theme === 'dark'}
onChange={(e) =>
setTheme(e.target.value as 'dark')
}
style={{ marginRight: '5px' }}
/>
ダークモード
</label>
</div>
</section>
{/* 通知設定 */}
<section style={{ marginBottom: '30px' }}>
<h3>通知設定</h3>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '10px',
}}
>
{Object.entries(settings.notifications).map(
([key, value]) => (
<label
key={key}
style={{
display: 'flex',
alignItems: 'center',
}}
>
<input
type='checkbox'
checked={value}
onChange={(e) =>
updateNotifications(
key as any,
e.target.checked
)
}
style={{ marginRight: '10px' }}
/>
{key === 'email'
? 'メール通知'
: key === 'push'
? 'プッシュ通知'
: 'SMS通知'}
</label>
)
)}
</div>
</section>
{/* 操作ボタン */}
<div style={{ display: 'flex', gap: '10px' }}>
<button
onClick={resetSettings}
style={{
padding: '10px 20px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
設定をリセット
</button>
</div>
</div>
);
}
プロジェクト 3:簡単なショッピングカート
最後は、実用的なショッピングカート機能を作成し、複雑な状態操作を学びます。
cart atoms の定義
typescript// src/atoms/cartAtoms.ts
import { atom } from 'jotai';
export interface Product {
id: number;
name: string;
price: number;
image: string;
}
export interface CartItem {
product: Product;
quantity: number;
}
// 商品一覧(通常はAPIから取得)
export const productsAtom = atom<Product[]>([
{ id: 1, name: 'ノートPC', price: 80000, image: '💻' },
{ id: 2, name: 'マウス', price: 3000, image: '🖱️' },
{ id: 3, name: 'キーボード', price: 8000, image: '⌨️' },
{ id: 4, name: 'モニター', price: 25000, image: '🖥️' },
{
id: 5,
name: 'ヘッドフォン',
price: 12000,
image: '🎧',
},
]);
// カートの中身
export const cartItemsAtom = atom<CartItem[]>([]);
// カートに商品を追加するatom
export const addToCartAtom = atom(
null,
(get, set, product: Product) => {
const currentItems = get(cartItemsAtom);
const existingItem = currentItems.find(
(item) => item.product.id === product.id
);
if (existingItem) {
// 既存商品の数量を増やす
set(
cartItemsAtom,
currentItems.map((item) =>
item.product.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
);
} else {
// 新しい商品を追加
set(cartItemsAtom, [
...currentItems,
{ product, quantity: 1 },
]);
}
}
);
// カートから商品を削除するatom
export const removeFromCartAtom = atom(
null,
(get, set, productId: number) => {
const currentItems = get(cartItemsAtom);
set(
cartItemsAtom,
currentItems.filter(
(item) => item.product.id !== productId
)
);
}
);
// 数量を更新するatom
export const updateQuantityAtom = atom(
null,
(
get,
set,
{
productId,
quantity,
}: { productId: number; quantity: number }
) => {
const currentItems = get(cartItemsAtom);
if (quantity <= 0) {
set(
cartItemsAtom,
currentItems.filter(
(item) => item.product.id !== productId
)
);
} else {
set(
cartItemsAtom,
currentItems.map((item) =>
item.product.id === productId
? { ...item, quantity }
: item
)
);
}
}
);
// 合計金額を計算する派生atom
export const totalPriceAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce(
(total, item) =>
total + item.product.price * item.quantity,
0
);
});
// 総商品数を計算する派生atom
export const totalItemsAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce(
(total, item) => total + item.quantity,
0
);
});
ShoppingCart コンポーネント
typescript// src/components/ShoppingCart.tsx
import React from 'react';
import { useAtom } from 'jotai';
import {
productsAtom,
cartItemsAtom,
addToCartAtom,
removeFromCartAtom,
updateQuantityAtom,
totalPriceAtom,
totalItemsAtom,
} from '../atoms/cartAtoms';
export function ShoppingCart() {
const [products] = useAtom(productsAtom);
const [cartItems] = useAtom(cartItemsAtom);
const [, addToCart] = useAtom(addToCartAtom);
const [, removeFromCart] = useAtom(removeFromCartAtom);
const [, updateQuantity] = useAtom(updateQuantityAtom);
const [totalPrice] = useAtom(totalPriceAtom);
const [totalItems] = useAtom(totalItemsAtom);
return (
<div
style={{
maxWidth: '800px',
margin: '0 auto',
padding: '20px',
}}
>
<h2>ショッピングカート</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 300px',
gap: '20px',
}}
>
{/* 商品一覧 */}
<div>
<h3>商品一覧</h3>
<div style={{ display: 'grid', gap: '15px' }}>
{products.map((product) => (
<div
key={product.id}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '15px',
display: 'flex',
alignItems: 'center',
gap: '15px',
}}
>
<div style={{ fontSize: '2em' }}>
{product.image}
</div>
<div style={{ flex: 1 }}>
<h4 style={{ margin: '0 0 5px 0' }}>
{product.name}
</h4>
<p
style={{
margin: 0,
fontSize: '1.2em',
fontWeight: 'bold',
}}
>
¥{product.price.toLocaleString()}
</p>
</div>
<button
onClick={() => addToCart(product)}
style={{
padding: '8px 16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
}}
>
カートに追加
</button>
</div>
))}
</div>
</div>
{/* カート */}
<div>
<h3>カート ({totalItems}点)</h3>
<div
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '15px',
position: 'sticky',
top: '20px',
}}
>
{cartItems.length === 0 ? (
<p>カートは空です</p>
) : (
<>
<div style={{ marginBottom: '15px' }}>
{cartItems.map((item) => (
<div
key={item.product.id}
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '10px',
paddingBottom: '10px',
borderBottom: '1px solid #eee',
}}
>
<div
style={{
fontSize: '1.5em',
marginRight: '10px',
}}
>
{item.product.image}
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '0.9em' }}>
{item.product.name}
</div>
<div
style={{
fontSize: '0.8em',
color: '#666',
}}
>
¥
{item.product.price.toLocaleString()}
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
}}
>
<button
onClick={() =>
updateQuantity({
productId: item.product.id,
quantity: item.quantity - 1,
})
}
style={{
padding: '2px 6px',
fontSize: '0.8em',
}}
>
-
</button>
<span
style={{
minWidth: '20px',
textAlign: 'center',
}}
>
{item.quantity}
</span>
<button
onClick={() =>
updateQuantity({
productId: item.product.id,
quantity: item.quantity + 1,
})
}
style={{
padding: '2px 6px',
fontSize: '0.8em',
}}
>
+
</button>
<button
onClick={() =>
removeFromCart(item.product.id)
}
style={{
padding: '2px 6px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '2px',
fontSize: '0.8em',
marginLeft: '5px',
}}
>
削除
</button>
</div>
</div>
))}
</div>
<div
style={{
fontSize: '1.2em',
fontWeight: 'bold',
textAlign: 'right',
marginBottom: '15px',
}}
>
合計: ¥{totalPrice.toLocaleString()}
</div>
<button
style={{
width: '100%',
padding: '12px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '1.1em',
}}
>
購入手続きへ
</button>
</>
)}
</div>
</div>
</div>
</div>
);
}
現場で使える実践テクニック
コンポーネント設計のベストプラクティス
実際の開発現場では、保守しやすく拡張性の高いコードを書くことが重要です。Jotai を使った効果的なコンポーネント設計のポイントを学びましょう。
1. Atom の責任範囲を明確にする
typescript// ❌ 悪い例:すべてを一つのatomに詰め込む
const appStateAtom = atom({
user: { name: '', email: '' },
todos: [],
theme: 'light',
cart: [],
// ...他にもたくさん
});
// ✅ 良い例:責任を分割する
const userAtom = atom({ name: '', email: '' });
const todosAtom = atom([]);
const themeAtom = atom('light');
const cartAtom = atom([]);
2. カスタムフックで複雑なロジックを抽象化する
typescript// src/hooks/useTodos.ts
import { useAtom } from 'jotai';
import { todosAtom } from '../atoms/todoAtoms';
export function useTodos() {
const [todos, setTodos] = useAtom(todosAtom);
const addTodo = (text: string) => {
const newTodo = {
id: Date.now(),
text,
completed: false,
createdAt: new Date(),
};
setTodos([...todos, newTodo]);
};
const toggleTodo = (id: number) => {
setTodos(
todos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
};
const deleteTodo = (id: number) => {
setTodos(todos.filter((todo) => todo.id !== id));
};
return {
todos,
addTodo,
toggleTodo,
deleteTodo,
};
}
// コンポーネントでの使用
function TodoList() {
const { todos, toggleTodo, deleteTodo } = useTodos();
return (
<div>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
))}
</div>
);
}
状態の適切な分割方法
状態をどのように分割するかは、アプリケーションの保守性に大きく影響します。
原則 1:関連性による分割
typescript// ユーザー関連の状態
export const userProfileAtom = atom({
name: '',
email: '',
avatar: '',
});
export const userPreferencesAtom = atom({
theme: 'light',
language: 'ja',
timezone: 'Asia/Tokyo',
});
// アプリケーション状態
export const appStateAtom = atom({
isLoading: false,
error: null,
currentPage: 'home',
});
原則 2:更新頻度による分割
typescript// 頻繁に更新される状態
export const currentTimeAtom = atom(new Date());
export const mousePositionAtom = atom({ x: 0, y: 0 });
// あまり変更されない状態
export const appConfigAtom = atom({
apiEndpoint: process.env.REACT_APP_API_URL,
version: '1.0.0',
});
チーム開発での注意点
チーム開発では、一貫性と可読性が重要になります。
命名規則の統一
typescript// ファイル構成例
src/
atoms/
user/ # ユーザー関連のatom
profile.ts
preferences.ts
authentication.ts
todo/ # Todo関連のatom
list.ts
filters.ts
stats.ts
app/ # アプリ全体に関わるatom
theme.ts
navigation.ts
notifications.ts
型定義の統一
typescript// src/types/index.ts
export interface User {
id: string;
name: string;
email: string;
}
export interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: Date;
updatedAt: Date;
}
// atom定義時は必ず型を明示
export const userAtom = atom<User | null>(null);
export const todosAtom = atom<Todo[]>([]);
トラブルシューティング完全ガイド
初心者によくある質問と回答
Q1: atom の値が更新されているのに画面が更新されない
A: 最も一般的な原因は、オブジェクトや配列の参照が変わっていないことです。
typescript// ❌ 間違い:既存の配列を変更(参照は同じまま)
const [todos, setTodos] = useAtom(todosAtom);
todos.push(newTodo); // 参照が変わらないので更新されない
setTodos(todos);
// ✅ 正解:新しい配列を作成
setTodos([...todos, newTodo]);
Q2: 複数のコンポーネントで同じ atom を使っているのに値が同期しない
A: atom のインポートパスが間違っているか、異なる atom インスタンスを使用している可能性があります。
typescript// ❌ 間違い:各ファイルで別々のatomを作成
// fileA.ts
const countAtom = atom(0);
// fileB.ts
const countAtom = atom(0); // 別のatomインスタンス!
// ✅ 正解:共通のファイルからインポート
// atoms/counter.ts
export const countAtom = atom(0);
// fileA.ts と fileB.ts
import { countAtom } from '../atoms/counter';
Q3: 非同期処理でエラーが発生する
A: Suspense と Error Boundary を適切に設置しましょう。
typescript// 非同期atom
export const dataAtom = atom(async () => {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error('データの取得に失敗しました');
}
return response.json();
});
// コンポーネント
function App() {
return (
<ErrorBoundary
fallback={<div>エラーが発生しました</div>}
>
<Suspense fallback={<div>読み込み中...</div>}>
<DataComponent />
</Suspense>
</ErrorBoundary>
);
}
エラー対処法
TypeScript エラーの対処
typescript// 型エラー:Type 'string' is not assignable to type 'number'
const numberAtom = atom(0);
const [count, setCount] = useAtom(numberAtom);
setCount('5'); // ❌ エラー
// 解決法:適切な型変換
setCount(Number('5')); // ✅ OK
setCount(parseInt('5', 10)); // ✅ OK
パフォーマンス問題の対処
typescript// 不要な再レンダリングを防ぐ
const expensiveComputationAtom = atom((get) => {
const data = get(dataAtom);
// 重い計算...
return heavyComputation(data);
});
// memo を使用して最適化
const ExpensiveComponent = React.memo(() => {
const [result] = useAtom(expensiveComputationAtom);
return <div>{result}</div>;
});
パフォーマンス改善のヒント
1. atom の分割を適切に行う
typescript// ❌ 一つの大きなオブジェクト
const userAtom = atom({
profile: { name: '', email: '' },
preferences: { theme: 'light' },
activity: { lastLogin: new Date() },
});
// ✅ 関心事ごとに分割
const userProfileAtom = atom({ name: '', email: '' });
const userPreferencesAtom = atom({ theme: 'light' });
const userActivityAtom = atom({ lastLogin: new Date() });
2. useAtomValue を活用する
typescript// 読み取り専用の場合は useAtomValue を使用
function DisplayComponent() {
const count = useAtomValue(countAtom); // 再レンダリング最適化
return <div>カウント: {count}</div>;
}
継続学習のロードマップ
Jotai の基礎を学んだ後の学習ステップをご提案します。
段階 1:基礎固め(1-2 週間)
- useState から Jotai への書き換え練習
- 基本的な CRUD アプリケーションの作成
- atom の分割・設計パターンの習得
段階 2:応用機能(2-3 週間)
- 非同期 atom の習得
- Suspense / Error Boundary との連携
- 派生 atom の活用
段階 3:実践プロジェクト(3-4 週間)
- 中規模アプリケーションの開発
- API との連携
- 状態の永続化(localStorage, sessionStorage)
段階 4:上級テクニック(1-2 週間)
- jotai-immer の活用
- jotai-optics の学習
- カスタム provider の実装
段階 5:エコシステム(継続的)
- React Router との統合
- Next.js での SSR 対応
- テストライブラリとの連携
推奨学習リソース
リソース | 難易度 | 内容 |
---|---|---|
Jotai 公式ドキュメント | 初級~上級 | 最新の情報と詳細な解説 |
React TypeScript Cheatsheet | 初級~中級 | TypeScript との連携 |
React Testing Library | 中級 | テストの書き方 |
Next.js ドキュメント | 中級~上級 | SSR/SSG との連携 |
まとめ
React の状態管理は複雑に見えがちですが、Jotai を使うことで驚くほどシンプルに、そして強力に扱うことができます。本記事で学んだ内容を振り返ってみましょう。
Jotai の最大の魅力は、その学習コストの低さと実用性の高さです。useState の延長線上で理解でき、複雑な設定も不要で、すぐに実践的なアプリケーションを作り始めることができます。
3 つのミニアプリを通じて、Todo 管理、ユーザー設定、ショッピングカートという異なるパターンの状態管理を体験しました。これらの経験は、実際の開発現場で必ず役立つでしょう。
実践テクニックでは、保守性の高いコード設計やチーム開発での注意点を学びました。これらは初心者のうちから意識することで、将来の開発効率が大きく向上します。
トラブルシューティングで学んだ内容は、実際の開発で必ず遭遇する問題への対処法です。エラーメッセージを恐れず、段階的にデバッグしていく習慣を身につけてください。
React の世界は広く、学習することは尽きませんが、Jotai という強力な武器を手に入れたことで、より複雑で実用的なアプリケーション開発への道筋が見えてきたのではないでしょうか。
今回学んだ基礎をもとに、ぜひ自分なりのアプリケーションを作成してみてください。小さなプロジェクトから始めて、徐々に機能を追加していくことで、自然と Jotai の活用スキルが向上していくはずです。
React 開発の旅路において、Jotai が皆さんの信頼できるパートナーとなることを願っています。継続的な学習を通じて、さらなるスキルアップを目指していきましょう!
関連リンク
- review
もう「なんとなく」で決めない!『解像度を上げる』馬田隆明著で身につけた、曖昧思考を一瞬で明晰にする技術
- review
もう疲れ知らず!『最高の体調』鈴木祐著で手に入れた、一生モノの健康習慣術
- review
人生が激変!『苦しかったときの話をしようか』森岡毅著で発見した、本当に幸せなキャリアの築き方
- review
もう「何言ってるの?」とは言わせない!『バナナの魅力を 100 文字で伝えてください』柿内尚文著 で今日からあなたも伝え方の達人!
- review
もう時間に追われない!『エッセンシャル思考』グレッグ・マキューンで本当に重要なことを見抜く!
- review
プロダクト開発の悩みを一刀両断!『プロダクトマネジメントのすべて』及川 卓也, 曽根原 春樹, 小城 久美子