5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React】Jotai を使った状態管理の全体像を理解する

Last updated at Posted at 2025-12-22

はじめに

この記事では、React / TypeScript 環境で Jotai を使った状態管理の実装方法を記載します。

Jotai とは

Jotai は、React アプリケーションのための軽量な状態管理ライブラリです。

Jotai はアトミックアプローチを採用しており、atom という小さな状態の単位を組み合わせて状態を構築します。atom の依存関係に基づいてレンダリングが自動的に最適化されます。以下のような特徴があります。

  • React の Context API の不要な再レンダリング問題を解決
    • 変更された状態を読み込んでいるコンポーネントのみ再レンダリングされる
  • useState のようなシンプルな API から、エンタープライズ規模の TypeScript アプリケーションまでスケール可能
  • TypeScript による完全な型安全性
  • 最小限の API と小さなバンドルサイズ
  • React Hooks に完全対応
  • メモ化の必要性を排除

Jotai(状態)という名前は日本語に由来しています。Jotai の設計は Recoil にインスパイアされており、ボトムアップアプローチ(まず最小の状態単位である個々の atom を定義し、そこからアプリケーション全体の状態を徐々に組み上げていく)で状態を管理します。

開発環境

開発環境は以下の通りです。

  • Windows 11
  • React 19.2.0
  • TypeScript 5.9.3
  • Vite 7.2.4
  • Node.js 24.11.1
  • npm 11.6.4
  • jotai 2.15.1

インストール

まずは以下のコマンドでインストールします。

npm install jotai

基本的な利用方法

atom の作成

Jotai の基本単位は atom です。atom に状態を定義します。

src/atoms ディレクトリを作成し、counterAtom.ts ファイルを作成します。

src/atoms/counterAtom.ts
import { atom } from "jotai";

export const counterAtom = atom(0);

atom を作成するには、初期値を指定するだけです。初期値には、プリミティブ値(文字列や数値)、オブジェクト、配列など、任意の値を指定できます。

useAtom フックの利用

useAtom フックは React の useState と同じ API を維持しています。

以下のように値の読み込み、更新ができます。

src/App.tsx
import { useAtom } from "jotai";
import { counterAtom } from "./atoms/counterAtom";

function App() {
  const [count, setCount] = useAtom(counterAtom);

  return (
    <div>
      <h1>カウント: {count}</h1>
      <button onClick={() => setCount((c) => c + 1)}>増やす</button>
      <button onClick={() => setCount((c) => c - 1)}>減らす</button>
    </div>
  );
}

export default App;

React の Context API と違い、Provider なしで利用できます。

ボタンをクリックすると、カウントが増減します。

jotai-Google-Chrome-2025-12-03-10-00-10.gif

読み取り専用・書き込み専用の hooks

パフォーマンスを最適化するために、読み取り専用または書き込み専用の hooks を使用できます。

useAtomValue(読み取り専用)

値の読み取りのみを行う場合は useAtomValue を使用します。

src/components/Display.tsx
import { useAtomValue } from "jotai";
import { counterAtom } from "../atoms/counterAtom";

export function Display() {
  const count = useAtomValue(counterAtom);

  return <div>現在の値: {count}</div>;
}

以下のように useAtom でも値の読み込みのみ行うことは可能です。

src/components/Display.tsx
import { useAtomValue } from "jotai";
import { counterAtom } from "../atoms/counterAtom";

export function Display() {
  const [count] = useAtom(counterAtom);

  return (
    <div>
      現在の値: {count}
    </div>
  );
}

ただ、たとえコード上で setCount を取り出さなくても、フックの返り値の型は [value, setValue] です。

Jotai のエコシステム内では、より厳密に「値の読み取りのみ」の目的で設計された useAtomValue の方が、将来的な動作保証や、ライブラリのアップデートによる潜在的な最適化の恩恵を受けやすいと考えられます。
特に、コンポーネントがアトムの値を読み取るだけ(書き込む操作がない)という意図を、コード上で明確に示せる点がメリットです。

useSetAtom(書き込み専用)

値の更新のみを行う場合は useSetAtom を使用します。

src/components/IncrementButton.tsx
import { useSetAtom } from "jotai";
import { counterAtom } from "../atoms/counterAtom";

export function IncrementButton() {
  const setCount = useSetAtom(counterAtom);

  return (
    <button onClick={() => setCount((c) => c + 1)}>
      インクリメント
    </button>
  );
}

こちらも useAtom で値の更新のみ行うことは可能です。

src/components/IncrementButton.tsx
import { useAtom } from "jotai";
import { counterAtom } from "../atoms/counterAtom";

export function IncrementButton() {
  const [, setCount] = useAtom(counterAtom);

  return (
    <button onClick={() => setCount((c) => c + 1)}>
      インクリメント
    </button>
  );
}

ただ、先述した理由に加え、count 更新時、IncrementButton コンポーネントの再レンダリングが防げるという点で、useSetAtom を利用するメリットがあります。

派生 atom(Derived Atoms)

既存の atom から新しい atom を作成できます。これにより、計算された値を管理できます。

読み取り専用の派生 atom

src/atoms/counterAtom.ts
import { atom } from "jotai";

export const counterAtom = atom(0);

/** counterAtom の2倍の値を持つ派生 atom */
export const doubledAtom = atom((get) => get(counterAtom) * 2);

/** counterAtom が偶数かどうかを判定する派生 atom */
export const isEvenAtom = atom((get) => get(counterAtom) % 2 === 0);

src/components/DerivedDisplay.tsx
import { useAtomValue } from "jotai";
import { counterAtom, doubledAtom, isEvenAtom } from "../atoms/counterAtom";

export function DerivedDisplay() {
  const count = useAtomValue(counterAtom);
  const doubled = useAtomValue(doubledAtom);
  const isEven = useAtomValue(isEvenAtom);

  return (
    <div>
      <p>カウント: {count}</p>
      <p>2倍: {doubled}</p>
      <p>{isEven ? "偶数です" : "奇数です"}</p>
    </div>
  );
}

読み書き可能な派生 atom

書き込み機能を持つ派生 atom も作成できます。

src/atoms/temperatureAtom.ts
import { atom } from "jotai";

/** 摂氏温度を保持する atom */
export const celsiusAtom = atom(0);

/** 華氏温度を計算し、設定もできる atom */
export const fahrenheitAtom = atom(
  (get) => (get(celsiusAtom) * 9) / 5 + 32,
  (get, set, newValue: number) => {
    set(celsiusAtom, ((newValue - 32) * 5) / 9);
  }
);
src/components/Temperature.tsx
import { useAtom, useAtomValue } from "jotai";
import { celsiusAtom, fahrenheitAtom } from "../atoms/temperatureAtom";

export function Temperature() {
  const [celsius, setCelsius] = useAtom(celsiusAtom);
  const [fahrenheit, setFahrenheit] = useAtom(fahrenheitAtom);

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

jotai-Google-Chrome-2025-12-03-18-12-34.gif

書き込み専用 atom

書き込み専用の atom は、アクションやイベントハンドラーとして機能します。

src/atoms/counterAtom.ts
import { atom } from "jotai";

export const counterAtom = atom(0);

// 書き込み専用の atom(第1引数に null を渡す慣習)
export const incrementAtom = atom(null, (get, set) => {
  set(counterAtom, get(counterAtom) + 1);
});

export const decrementAtom = atom(null, (get, set) => {
  set(counterAtom, get(counterAtom) - 1);
});

export const resetAtom = atom(null, (get, set) => {
  set(counterAtom, 0);
});
src/components/CounterActions.tsx
import { useSetAtom, useAtomValue } from "jotai";
import {
  counterAtom,
  incrementAtom,
  decrementAtom,
  resetAtom,
} from "../atoms/counterAtom";

export function CounterActions() {
  const count = useAtomValue(counterAtom);
  const increment = useSetAtom(incrementAtom);
  const decrement = useSetAtom(decrementAtom);
  const reset = useSetAtom(resetAtom);

  return (
    <div>
      <p>カウント: {count}</p>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>リセット</button>
    </div>
  );
}

jotai-Google-Chrome-2025-12-03-18-28-12.gif

読み書き atom のメリットデメリットについて

  • メリット

    • 集中管理と再利用性: 複数のコンポーネントから同じアクション(例:「Todoを完了済みにする」)を実行する場合、ロジックが atom の定義ファイルの一箇所にまとまるため、再利用が容易で、どこから呼び出しても同じ動作が保証される
    • コンポーネントのクリーン化: コンポーネントが純粋に状態の表示と、atom に定義されたアクションの呼び出しに専念できるため、ロジックが少なくなりクリーンになる
    • テストの容易性: ビジネスロジックがプレーンな JavaScript/TypeScript の関数であるatom の定義内に閉じ込められるため、テストしやすくなる
  • デメリット

    • 定義ファイルが肥大化しやすい: atom の数が増えるにつれて、関連するロジック(アクション)も増え、一つの atom 定義ファイルが長大になる
    • コンポーネント固有の処理は不向き: アクションが特定のコンポーネントでしか使われない、あるいは DOM 操作などの副作用を含む場合、atom 側で定義するのは不自然

非同期 atom

Jotai は非同期処理をネイティブにサポートしています。

src/atoms/userAtom.ts
import { atom } from "jotai";

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

export const userIdAtom = atom(1);

export const userAtom = atom(async (get) => {
  const userId = get(userIdAtom);
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  );
  const user: User = await response.json();
  return user;
});
src/components/UserProfile.tsx
import { Suspense } from "react";
import { useAtom, useAtomValue } from "jotai";
import { userIdAtom, userAtom } from "../atoms/userAtom";

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

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

export function UserProfile() {
  const [userId, setUserId] = useAtom(userIdAtom);

  return (
    <div>
      <input
        type="number"
        value={userId}
        onChange={(e) => setUserId(Number(e.target.value))}
      />
      <Suspense fallback={<div>読み込み中...</div>}>
        <UserData />
      </Suspense>
    </div>
  );
}

非同期 atom を使用する場合は、使用するコンポーネントを Suspense コンポーネントでラップする必要があります。

localStorage への永続化

atomWithStorage 関数は、localStorage や sessionStorage、React Native の AsyncStorage に値を永続化する atom を作成します。

src/atoms/settingsAtom.ts
import { atomWithStorage } from "jotai/utils";

export const themeAtom = atomWithStorage<"light" | "dark">("theme", "light");

export const userSettingsAtom = atomWithStorage("userSettings", {
  language: "ja",
  notifications: true,
});
src/components/Settings.tsx
import { useAtom } from "jotai";
import { themeAtom, userSettingsAtom } from "../atoms/settingsAtom";

export function Settings() {
  const [theme, setTheme] = useAtom(themeAtom);
  const [settings, setSettings] = useAtom(userSettingsAtom);

  return (
    <div>
      <div>
        <label>テーマ: </label>
        <select value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
          <option value="light">ライト</option>
          <option value="dark">ダーク</option>
        </select>
      </div>
      <div>
        <label>
          <input
            type="checkbox"
            checked={settings.notifications}
            onChange={(e) =>
              setSettings({ ...settings, notifications: e.target.checked })
            }
          />
          通知を有効にする
        </label>
      </div>
    </div>
  );
}

atomWithStorage は、値が変更されると自動的に localStorage や sessionStorage に同期し、初回読み込み時に自動的に値を取得します。

ブラウザの開発者ツールで localStorage を確認すると、設定が保存されていることが確認できます。

image.png

カスタムストレージの指定

atomWithStorage には、key(必須)、initialValue(必須)、storage(オプション)、options(オプション)を指定できます。デフォルトでは localStorage が使用されますが、sessionStorage など他のストレージを指定することもできます。

import { atomWithStorage } from "jotai/utils";

// sessionStorage を使用
export const sessionThemeAtom = atomWithStorage(
  "theme",
  "light",
  sessionStorage
);

Provider の使用

通常、Jotai は Provider なしでも動作しますが、複数の独立した状態ツリーを持ちたい場合や、サーバーサイドレンダリング(SSR)を使用する場合は Provider が必要です。

src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "jotai";
import App from "./App";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <Provider>
      <App />
    </Provider>
  </React.StrictMode>
);

Provider を使用することで、スコープを分離した状態管理が可能になります。

src/App.tsx
import { Provider } from "jotai";
import { Counter } from "./components/Counter";

function App() {
  return (
    <div>
      <h1>カウンター1</h1>
      <Provider>
        <Counter />
      </Provider>

      <h1>カウンター2</h1>
      <Provider>
        <Counter />
      </Provider>
    </div>
  );
}

export default App;

この例では、2つの独立したカウンターが動作します。

TypeScript の型定義

Jotai は TypeScript で書かれており、完全な型サポートを提供します。

src/atoms/todoAtom.ts
import { atom } from "jotai";

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

export const todosAtom = atom<Todo[]>([]);

export const addTodoAtom = atom(null, (get, set, text: string) => {
  const todos = get(todosAtom);
  const newTodo: Todo = {
    id: Date.now(),
    text,
    completed: false,
  };
  set(todosAtom, [...todos, newTodo]);
});

export const toggleTodoAtom = atom(null, (get, set, id: number) => {
  const todos = get(todosAtom);
  set(
    todosAtom,
    todos.map((todo) =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )
  );
});

export const completedTodosAtom = atom((get) =>
  get(todosAtom).filter((todo) => todo.completed)
);
src/components/TodoList.tsx
import { useState } from "react";
import { useAtomValue, useSetAtom } from "jotai";
import {
  todosAtom,
  addTodoAtom,
  toggleTodoAtom,
  completedTodosAtom,
} from "../atoms/todoAtom";

export function TodoList() {
  const [input, setInput] = useState("");
  const todos = useAtomValue(todosAtom);
  const addTodo = useSetAtom(addTodoAtom);
  const toggleTodo = useSetAtom(toggleTodoAtom);
  const completedTodos = useAtomValue(completedTodosAtom);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (input.trim()) {
      addTodo(input);
      setInput("");
    }
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="新しいタスク"
        />
        <button type="submit">追加</button>
      </form>

      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span
              style={{
                textDecoration: todo.completed ? "line-through" : "none",
              }}
            >
              {todo.text}
            </span>
          </li>
        ))}
      </ul>

      <p>完了: {completedTodos.length} / {todos.length}</p>
    </div>
  );
}

TypeScript を使用することで、atom の型が自動的に推論され、型安全なコードを書くことができます。

まとめ

Jotai を使うことで、React / TypeScript アプリケーションにシンプルで強力な状態管理を導入できます。

主なポイントは以下の通りです。

  • useState と同じような直感的な API
  • atom という小さな状態の単位を組み合わせて複雑な状態を構築
  • 派生 atom による計算された値の管理
  • 非同期処理のネイティブサポート
  • localStorage への簡単な永続化
  • TypeScript による完全な型安全性
  • 不要な再レンダリングの自動的な最適化

Jotai は小規模なプロジェクトから大規模なエンタープライズアプリケーションまで、柔軟にスケールできる状態管理ライブラリです。Redux のような大規模な設定は不要で、Context API の再レンダリング問題も解決します。

参考

関連記事

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?