はじめに
前回、ゲーム風Todoアプリを通してuseReducerの使い方を解説では、useReducer を使ってゲーム風 Todo アプリを作成しました。
今回は、同じアプリを jotai の Write Only atoms(書き込み専用アトム) を使って実装し直します。
なぜ状態管理ライブラリを使うのか?
前回の useReducer 実装では、App.tsx で状態を管理し、props で各コンポーネントに渡していました。
しかし、アプリが成長すると以下のような課題が出てきます。
想定される機能拡張
- Todo 管理画面とスコア一覧画面など、複数の画面で同じ情報を使いたい
機能拡張によって発生しうる問題
- 各画面で別々に API を呼び出すと通信回数が増えてしまう
- props のバケツリレーが深くなり、コードが煩雑になる
このような問題は、以下利点により状態管理ライブラリを使うと改善することができます。
- グローバルな状態を複数のコンポーネントから直接参照できる
- つまり、1 度取得したデータを複数の画面で共有できる(API 呼び出し削減)
- props のバケツリレーが不要になる
jotai とは?
jotai はアプリケーションの状態をデ-タとして表示し、そのデータの変更や更新を一元的に管理することができる軽量なReactの状態管理ライブラリのひとつです。
Jotaiが提供しているもののうち Write Only atoms(書き込み専用アトム) は、useReducer のdispatchに相当する機能を提供し、状態更新ロジックをアトム内にカプセル化できる強力な機能です。
Write Only atoms とは?
アトムの値を変更するアトムです。アトムの値を直接更新するよりも、余分な再レンダリングが発生しないという利点があります。
参照元;Jotai公式のチュートリアル - Write Only atoms
本記事では、useReducer から jotai への移行を通して、Write Only atoms の使い方と、複数画面での状態共有を見据えた実装方法を解説していきます。
前提知識
この記事はゲーム風Todoアプリを通してuseReducerの使い方を解説の続編です。
アプリの仕様や基本的な実装については、前回の記事をご確認してください。
jotai のインストール
まずは jotai をインストールします。
npm install jotai
ステップ 1: 基本アトムの定義
まず、状態を保持する基本アトムを定義します。
今回は、useReducerからJotaiへの移行ということもあり、Task, Player状態の型定義は前回の記事と同様のものを用います。
import { atom } from "jotai";
// Task型定義
export interface Task {
id: number;
title: string;
completed: boolean;
exp: number;
monsterImage: string;
deadline?: string;
}
// Player状態型定義
export interface PlayerState {
hp: number;
maxHp: number;
level: number;
exp: number;
expToNextLevel: number;
}
// ===== 基本アトム(状態を保持) =====
export const tasksAtom = atom<Task[]>([]);
export const nextTaskIdAtom = atom<number>(1);
export const levelUpMessageAtom = atom<string | null>(null);
export const isGameOverAtom = atom<boolean>(false);
export const playerAtom = atom<PlayerState>({
hp: 100,
maxHp: 100,
level: 1,
exp: 0,
expToNextLevel: 100,
});
ステップ 2: Write Only atoms(書き込み専用アトム)の実装
Write Only atoms は、第一引数にnullを指定し、第二引数に更新関数を定義します。
これが useReducer のreducer関数に相当します。
タスク追加アトムの例
// タスク追加アトム
export const addTaskAtom = atom(
null, // 第一引数: nullで読み取り不可
(get, set, payload: { title: string; deadline?: string }) => {
// ゲームオーバー時は何もしない
if (get(isGameOverAtom)) return;
const monsterNumber = Math.floor(Math.random() * 6) + 1;
const monsterImage = `/src/asset/monster/monster0${monsterNumber}.png`;
const newTask: Task = {
id: get(nextTaskIdAtom),
title: payload.title,
completed: false,
exp: Math.floor(Math.random() * 30) + 20,
monsterImage,
deadline: payload.deadline,
};
set(tasksAtom, [...get(tasksAtom), newTask]);
set(nextTaskIdAtom, get(nextTaskIdAtom) + 1);
// タスク追加後に期限切れチェック(他のアトムを呼び出し)
set(checkExpiredTasksAtom);
}
);
Write Only atom のポイント
-
get: 他のアトムの値を読み取る -
set: 他のアトムの値を更新する -
payload: useReducer のaction.payloadに相当
タスク完了アトムの実装
useReducer のCOMPLETE_TASKケースと同じロジックを Write Only atom で実装します。
export const completeTaskAtom = atom(null, (get, set, taskId: number) => {
if (get(isGameOverAtom)) return;
const tasks = get(tasksAtom);
const task = tasks.find((t) => t.id === taskId);
if (!task || task.completed) return;
// タスクを完了状態に更新
const updatedTasks = tasks.map((t) =>
t.id === taskId ? { ...t, completed: true } : t
);
set(tasksAtom, updatedTasks);
// 経験値を加算
const player = get(playerAtom);
let newExp = player.exp + task.exp;
let newLevel = player.level;
let newExpToNextLevel = player.expToNextLevel;
let levelUpMessage: string | null = null;
// レベルアップ判定
while (newExp >= newExpToNextLevel) {
newExp -= newExpToNextLevel;
newLevel += 1;
newExpToNextLevel = calculateExpToNextLevel(newLevel);
levelUpMessage = `レベルアップ! Lv.${newLevel} になりました!`;
}
// レベルアップ時にHPを全回復
const newMaxHp = 100 + (newLevel - 1) * 10;
const newHp = newLevel > player.level ? newMaxHp : player.hp;
set(playerAtom, {
hp: newHp,
maxHp: newMaxHp,
level: newLevel,
exp: newExp,
expToNextLevel: newExpToNextLevel,
});
if (levelUpMessage) {
set(levelUpMessageAtom, levelUpMessage);
}
// タスク完了後に期限切れチェック(他のアトムを呼び出し)
set(checkExpiredTasksAtom);
});
期限切れチェックアトム
// 期限切れチェックアトム
export const checkExpiredTasksAtom = atom(
null,
(get, set) => {
if (get(isGameOverAtom)) return;
const now = new Date();
const tasks = get(tasksAtom);
const expiredTasks = tasks.filter(
(task: Task) => !task.completed && task.deadline && new Date(task.deadline) < now
);
if (expiredTasks.length === 0) return;
const damagePerTask = 5;
const totalDamage = expiredTasks.length * damagePerTask;
const player = get(playerAtom);
const newHp = Math.max(0, player.hp - totalDamage);
set(playerAtom, {
...player,
hp: newHp,
});
if (newHp === 0) {
set(isGameOverAtom, true);
}
}
);
// その他のアトム省略...
ステップ 3: コンポーネントでの使用
App.tsx の実装
useReducer から jotai への移行による変更点を diff で示します。
// importは省略
function App() {
- const [state, dispatch] = useReducer(appReducer, initialState);
+ // 読み取り用アトム
+ const [tasks] = useAtom(tasksAtom);
+ const [player] = useAtom(playerAtom);
+ const [levelUpMessage] = useAtom(levelUpMessageAtom);
+ const [isGameOver] = useAtom(isGameOverAtom);
+
+ // 書き込み専用アトム
+ const addTask = useSetAtom(addTaskAtom);
+ const completeTask = useSetAtom(completeTaskAtom);
+ const deleteTask = useSetAtom(deleteTaskAtom);
+ const clearLevelUpMessage = useSetAtom(clearLevelUpMessageAtom);
+ const checkExpiredTasks = useSetAtom(checkExpiredTasksAtom);
+ const resetGame = useSetAtom(resetGameAtom);
// 初回マウント時に期限切れチェック
useEffect(() => {
- dispatch({ type: 'CHECK_EXPIRED_TASKS' });
- }, []);
+ checkExpiredTasks();
+ }, [checkExpiredTasks]);
const handleAddTask = (title: string, deadline?: string) => {
- dispatch({ type: 'ADD_TASK', payload: { title, deadline } });
- dispatch({ type: 'CHECK_EXPIRED_TASKS' });
+ addTask({ title, deadline });
};
const handleCompleteTask = (id: number) => {
- dispatch({ type: 'COMPLETE_TASK', payload: { id } });
- dispatch({ type: 'CHECK_EXPIRED_TASKS' });
+ completeTask(id);
};
const handleDeleteTask = (id: number) => {
- dispatch({ type: 'DELETE_TASK', payload: { id } });
- dispatch({ type: 'CHECK_EXPIRED_TASKS' });
+ deleteTask(id);
};
const handleClearLevelUpMessage = () => {
- dispatch({ type: 'CLEAR_LEVEL_UP_MESSAGE' });
+ clearLevelUpMessage();
};
const handleResetGame = () => {
- dispatch({ type: 'RESET_GAME' });
+ resetGame();
};
return (
<div className="app">
- <Header player={state.player} />
+ <Header player={player} />
<main className="main-content">
<AddTask onAddTask={handleAddTask} />
<TaskList
- tasks={state.tasks}
+ tasks={tasks}
onCompleteTask={handleCompleteTask}
onDeleteTask={handleDeleteTask}
/>
</main>
<Footer
- tasks={state.tasks}
- levelUpMessage={state.levelUpMessage}
+ tasks={tasks}
+ levelUpMessage={levelUpMessage}
onClearLevelUpMessage={handleClearLevelUpMessage}
/>
- {state.isGameOver && <GameOver onReset={handleResetGame} />}
+ {isGameOver && <GameOver onReset={handleResetGame} />}
</div>
);
}
export default App;
主な変更点
-
状態の取得:
state.xxx→ 個別のアトムから直接取得 -
状態の更新:
dispatch({ type: ... })→ 関数呼び出し
useReducer と jotai Write Only atoms の比較
useReducer 版と jotai 版のコード比較
// reducer.ts
export const appReducer = (state: AppState, action: Action): AppState => {
switch (action.type) {
case "COMPLETE_TASK": {
// ロジック...
return newState;
}
// ...
}
};
// App.tsx
const [state, dispatch] = useReducer(appReducer, initialState);
dispatch({ type: "COMPLETE_TASK", payload: { id } });
// atoms.ts
export const completeTaskAtom = atom(null, (get, set, taskId: number) => {
// ロジック...
});
// App.tsx
const completeTask = useSetAtom(completeTaskAtom);
completeTask(id);
Write Only atoms のメリット
1. より直感的な API
- // useReducer
- dispatch({ type: "COMPLETE_TASK", payload: { id } });
+ // jotai
+ completeTask(id);
- 文字列の
typeが不要 - 関数呼び出しとして自然
2. アトムの組み合わせが容易
const addTaskAtom = atom(null, (get, set, payload) => {
// タスク追加ロジック
// 他のWrite Only atomを呼び出せる
set(checkExpiredTasksAtom);
});
3. 細かい単位でのテストが可能
// 各アトムを個別にテストできる
it("タスク追加アトムが正しく動作する", () => {
const store = createStore();
store.set(addTaskAtom, { title: "テスト", deadline: undefined });
const tasks = store.get(tasksAtom);
expect(tasks).toHaveLength(1);
expect(tasks[0].title).toBe("テスト");
});
4. コンポーネント間での状態共有が簡単
useReducer では、stateとdispatchを props で渡す必要がありましたが、jotai ではコンポーネントから直接アトムを使用できます。
// Header.tsx
import { useAtom } from "jotai";
import { playerAtom } from "./atoms";
function Header() {
const [player] = useAtom(playerAtom); // propsなしで直接アクセス
return (
<header>
<div>
HP: {player.hp}/{player.maxHp}
</div>
<div>Lv: {player.level}</div>
</header>
);
}
useReducer を使うべきケース vs jotai を使うべきケース
useReducer が適している
- 局所的な状態管理: 1 つのコンポーネント内で完結する複雑な状態
- 外部ライブラリ不要: 追加の依存関係を避けたい
jotai が適している
- グローバルな状態管理: 複数コンポーネント間での状態共有
- 細かい再レンダリング制御: 必要な部分だけを更新したい
まとめ
jotai の Write Only atoms は、useReducer の良さ(ロジックの集約、予測可能性)を保ちつつ、より直感的で柔軟な状態管理を実現できます。
Write Only atoms の特徴
-
関数的な API -
dispatch({ type: ... })より直感的 - 細かいモジュール化 - 各アクションを独立したアトムとして定義
- アトムの組み合わせ - 他の Write Only atoms を呼び出して再利用可能
特に、複数コンポーネント間で状態を共有する必要がある場合や、段階的に状態管理を改善したい場合には、jotai の Write Only atoms は強力な選択肢になってくるので、使ってみると良いかもしれません。