T-CREATOR

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

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 状態管理の学習順序として、以下をおすすめします:

  1. useState をマスターする(1-2 週間)

    • 基本的な状態の更新
    • 配列やオブジェクトの更新パターン
  2. Jotai で状態共有を学ぶ(2-3 週間)

    • atom の基本概念
    • コンポーネント間での状態共有
    • 実践的なアプリケーション作成
  3. 必要に応じて他の手法を学ぶ

    • より大規模なアプリでは 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 snippetsReact コード補完必須
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 の活用

  1. Chrome に React Developer Tools 拡張機能をインストール
  2. 開発者ツールで Components タブを確認
  3. 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 が皆さんの信頼できるパートナーとなることを願っています。継続的な学習を通じて、さらなるスキルアップを目指していきましょう!

関連リンク