はじめに
この記事では、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 ファイルを作成します。
import { atom } from "jotai";
export const counterAtom = atom(0);
atom を作成するには、初期値を指定するだけです。初期値には、プリミティブ値(文字列や数値)、オブジェクト、配列など、任意の値を指定できます。
useAtom フックの利用
useAtom フックは React の useState と同じ API を維持しています。
以下のように値の読み込み、更新ができます。
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 なしで利用できます。
ボタンをクリックすると、カウントが増減します。
読み取り専用・書き込み専用の hooks
パフォーマンスを最適化するために、読み取り専用または書き込み専用の hooks を使用できます。
useAtomValue(読み取り専用)
値の読み取りのみを行う場合は useAtomValue を使用します。
import { useAtomValue } from "jotai";
import { counterAtom } from "../atoms/counterAtom";
export function Display() {
const count = useAtomValue(counterAtom);
return <div>現在の値: {count}</div>;
}
以下のように useAtom でも値の読み込みのみ行うことは可能です。
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 を使用します。
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 で値の更新のみ行うことは可能です。
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
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);
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 も作成できます。
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);
}
);
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>
);
}
書き込み専用 atom
書き込み専用の atom は、アクションやイベントハンドラーとして機能します。
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);
});
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>
);
}
読み書き atom のメリットデメリットについて
-
メリット
- 集中管理と再利用性: 複数のコンポーネントから同じアクション(例:「Todoを完了済みにする」)を実行する場合、ロジックが atom の定義ファイルの一箇所にまとまるため、再利用が容易で、どこから呼び出しても同じ動作が保証される
- コンポーネントのクリーン化: コンポーネントが純粋に状態の表示と、atom に定義されたアクションの呼び出しに専念できるため、ロジックが少なくなりクリーンになる
- テストの容易性: ビジネスロジックがプレーンな JavaScript/TypeScript の関数であるatom の定義内に閉じ込められるため、テストしやすくなる
-
デメリット
- 定義ファイルが肥大化しやすい: atom の数が増えるにつれて、関連するロジック(アクション)も増え、一つの atom 定義ファイルが長大になる
- コンポーネント固有の処理は不向き: アクションが特定のコンポーネントでしか使われない、あるいは DOM 操作などの副作用を含む場合、atom 側で定義するのは不自然
非同期 atom
Jotai は非同期処理をネイティブにサポートしています。
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;
});
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 を作成します。
import { atomWithStorage } from "jotai/utils";
export const themeAtom = atomWithStorage<"light" | "dark">("theme", "light");
export const userSettingsAtom = atomWithStorage("userSettings", {
language: "ja",
notifications: true,
});
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 を確認すると、設定が保存されていることが確認できます。
カスタムストレージの指定
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 が必要です。
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 を使用することで、スコープを分離した状態管理が可能になります。
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 で書かれており、完全な型サポートを提供します。
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)
);
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 の再レンダリング問題も解決します。
参考
関連記事



