Undo/Redo を備えた履歴つき状態管理を Jotai で設計する

現代の Web アプリケーション開発において、状態管理は複雑化の一途を辿っています。特にユーザーの操作を元に戻したり、やり直したりする Undo/Redo 機能は、ユーザビリティの向上に欠かせない要素となりました。
本記事では、軽量で柔軟な React 状態管理ライブラリ「Jotai」を使用して、効率的な Undo/Redo 機能を持つ状態管理システムを構築する方法をご紹介します。基礎概念から実装方法、さらにはパフォーマンス最適化まで、段階的に解説していきますね。
Jotai とは何か
Jotai は、React 専用の原子的(Atomic)状態管理ライブラリです。従来の状態管理ライブラリと比較して、以下のような特徴があります。
Jotai の主な特徴
特徴 | 説明 |
---|---|
Bottom-up アプローチ | 小さな atom から大きな状態を組み立てる |
TypeScript 完全対応 | 型安全性が保証される |
軽量性 | バンドルサイズが小さい |
学習コストの低さ | シンプルな API で習得しやすい |
以下は、Jotai の基本的な状態管理の仕組みを図で表したものです。
mermaidflowchart TD
atom1[countAtom] --> derived[derivedAtom]
atom2[nameAtom] --> derived
derived --> comp1[Component A]
derived --> comp2[Component B]
comp1 --> |更新| atom1
comp2 --> |更新| atom2
このように、複数の小さな atom を組み合わせて、複雑な状態を管理できるのが Jotai の強みですね。
Jotai の基本的な使い方
まずは、Jotai の基本的な atom の作成と使用方法を見てみましょう。
typescriptimport { atom, useAtom } from 'jotai';
// 基本的なatom の作成
const countAtom = atom(0);
typescript// コンポーネントでの使用
function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>
増加
</button>
</div>
);
}
この例では、カウンターの値を管理する基本的な atom を作成し、コンポーネント内で使用しています。
状態管理における Undo/Redo の必要性
ユーザビリティの向上
現代の Web アプリケーションでは、ユーザーが誤った操作を行った際に、簡単に元に戻せることが重要です。特に以下のような場面で Undo/Redo 機能は重宝されます。
用途 | 具体例 |
---|---|
テキストエディタ | 文章の編集作業 |
画像編集ツール | フィルターや加工の適用 |
フォーム入力 | 長いフォームでの入力ミス |
データ可視化 | グラフの設定変更 |
従来の状態管理での課題
従来の Redux や Zustand などで Undo/Redo 機能を実装する場合、以下のような課題がありました。
mermaidflowchart LR
state[現在の状態] --> history[履歴配列]
history --> |操作| newState[新しい状態]
newState --> |全体コピー| bigHistory[巨大な履歴]
bigHistory --> |メモリ問題| performance[パフォーマンス低下]
このような問題を解決するために、Jotai の原子的アプローチが効果的な解決策となります。
Jotai での履歴管理の基本概念
Atomic な状態管理の利点
Jotai では、状態を小さな atom に分割することで、履歴管理を効率的に行えます。これにより、以下のメリットが得られますね。
mermaidstateDiagram-v2
[*] --> CurrentState
CurrentState --> HistoryAtom: 状態変更
HistoryAtom --> UndoState: Undo実行
UndoState --> CurrentState: 状態復元
HistoryAtom --> RedoState: Redo実行
RedoState --> CurrentState: 状態復元
この図は、Jotai での状態変更と Undo/Redo 操作の基本的なフローを表しています。各操作が独立して管理されるため、複雑な状態でも効率的に履歴を追跡できます。
履歴管理の基本要素
効果的な履歴管理システムには、以下の要素が必要です。
typescript// 履歴管理の基本的な型定義
interface HistoryState<T> {
past: T[]; // 過去の状態配列
present: T; // 現在の状態
future: T[]; // 未来の状態配列(Redo用)
}
typescript// 基本的な履歴操作の型定義
interface HistoryActions {
undo: () => void;
redo: () => void;
canUndo: boolean;
canRedo: boolean;
}
Undo/Redo 機能の設計パターン
コマンドパターンの採用
効率的な Undo/Redo 機能を実装するために、コマンドパターンを採用します。これにより、各操作を独立したコマンドとして管理できます。
typescript// コマンドインターフェース
interface Command<T> {
execute: (state: T) => T;
undo: (state: T) => T;
description: string;
}
typescript// 具体的なコマンド実装例
class IncrementCommand implements Command<number> {
constructor(private amount: number = 1) {}
execute(state: number): number {
return state + this.amount;
}
undo(state: number): number {
return state - this.amount;
}
description = `${this.amount}だけ増加`;
}
設計パターンの選択基準
パターン | 適用場面 | メリット | デメリット |
---|---|---|---|
スナップショット方式 | 単純な状態構造 | 実装が簡単 | メモリ使用量が多い |
コマンドパターン | 複雑な操作 | メモリ効率が良い | 実装が複雑 |
差分管理方式 | 大きなオブジェクト | 高性能 | デバッグが困難 |
atomWithHistory の実装
基本的な atomWithHistory
まずは、履歴機能を持つ atom を作成してみましょう。
typescriptimport { atom } from 'jotai'
function atomWithHistory<T>(initialValue: T) {
// 履歴状態を管理するatom
const historyAtom = atom<HistoryState<T>>({
past: [],
present: initialValue,
future: []
})
typescript// 読み取り専用の現在値atom
const valueAtom = atom((get) => get(historyAtom).present);
// 値を更新するatom
const updateAtom = atom(null, (get, set, newValue: T) => {
const currentHistory = get(historyAtom);
set(historyAtom, {
past: [...currentHistory.past, currentHistory.present],
present: newValue,
future: [], // 新しい操作でRedoの履歴をクリア
});
});
typescript// Undo操作のatom
const undoAtom = atom(null, (get, set) => {
const currentHistory = get(historyAtom);
if (currentHistory.past.length > 0) {
const previous =
currentHistory.past[currentHistory.past.length - 1];
const newPast = currentHistory.past.slice(0, -1);
set(historyAtom, {
past: newPast,
present: previous,
future: [
currentHistory.present,
...currentHistory.future,
],
});
}
});
typescript// Redo操作のatom
const redoAtom = atom(null, (get, set) => {
const currentHistory = get(historyAtom);
if (currentHistory.future.length > 0) {
const next = currentHistory.future[0];
const newFuture = currentHistory.future.slice(1);
set(historyAtom, {
past: [
...currentHistory.past,
currentHistory.present,
],
present: next,
future: newFuture,
});
}
});
typescript // Undo/Redoの可否を判定するatom
const canUndoAtom = atom((get) => get(historyAtom).past.length > 0)
const canRedoAtom = atom((get) => get(historyAtom).future.length > 0)
return {
valueAtom,
updateAtom,
undoAtom,
redoAtom,
canUndoAtom,
canRedoAtom,
historyAtom
}
}
使用方法の例
作成した atomWithHistory を実際に使用してみましょう。
typescript// 履歴付きカウンターatomの作成
const {
valueAtom: countAtom,
updateAtom: setCountAtom,
undoAtom,
redoAtom,
canUndoAtom,
canRedoAtom,
} = atomWithHistory(0);
typescript// コンポーネントでの使用
function HistoryCounter() {
const [count] = useAtom(countAtom)
const [, setCount] = useAtom(setCountAtom)
const [, undo] = useAtom(undoAtom)
const [, redo] = useAtom(redoAtom)
const [canUndo] = useAtom(canUndoAtom)
const [canRedo] = useAtom(canRedoAtom)
typescript return (
<div>
<h2>履歴付きカウンター</h2>
<p>現在の値: {count}</p>
<button onClick={() => setCount(count + 1)}>
+1
</button>
<button onClick={() => setCount(count - 1)}>
-1
</button>
<div>
<button onClick={undo} disabled={!canUndo}>
Undo
</button>
<button onClick={redo} disabled={!canRedo}>
Redo
</button>
</div>
</div>
)
}
コマンドパターンによる状態変更の管理
高度なコマンドシステム
より複雑な状態管理では、コマンドパターンを活用することで、柔軟性と拡張性を持った履歴管理が可能になります。
typescript// 抽象コマンドクラス
abstract class UndoableCommand<T> {
abstract execute(state: T): T;
abstract undo(state: T): T;
abstract description: string;
// タイムスタンプを記録
readonly timestamp = Date.now();
}
typescript// テキスト挿入コマンド
class InsertTextCommand extends UndoableCommand<string> {
constructor(
private text: string,
private position: number
) {
super();
}
execute(state: string): string {
return (
state.slice(0, this.position) +
this.text +
state.slice(this.position)
);
}
undo(state: string): string {
const start = this.position;
const end = this.position + this.text.length;
return state.slice(0, start) + state.slice(end);
}
description = `"${this.text}"をposition ${this.position}に挿入`;
}
コマンド履歴を管理する atom
typescriptfunction atomWithCommandHistory<T>(initialValue: T) {
// コマンド履歴を管理
const commandHistoryAtom = atom<{
past: UndoableCommand<T>[]
future: UndoableCommand<T>[]
present: T
}>({
past: [],
future: [],
present: initialValue
})
typescript// コマンドを実行するatom
const executeCommandAtom = atom(
null,
(get, set, command: UndoableCommand<T>) => {
const current = get(commandHistoryAtom);
const newState = command.execute(current.present);
set(commandHistoryAtom, {
past: [...current.past, command],
present: newState,
future: [], // 新しいコマンドでfutureをクリア
});
}
);
typescript// Undo実行atom
const undoCommandAtom = atom(null, (get, set) => {
const current = get(commandHistoryAtom);
if (current.past.length > 0) {
const lastCommand =
current.past[current.past.length - 1];
const undoState = lastCommand.undo(current.present);
set(commandHistoryAtom, {
past: current.past.slice(0, -1),
present: undoState,
future: [lastCommand, ...current.future],
});
}
});
実用的な Undo/Redo 機能の実装例
テキストエディタの実装
実際のアプリケーションでの使用例として、簡単なテキストエディタを実装してみましょう。
typescript// テキストエディタ用のatom
const {
valueAtom: textAtom,
updateAtom: setTextAtom,
undoAtom: undoTextAtom,
redoAtom: redoTextAtom,
canUndoAtom: canUndoTextAtom,
canRedoAtom: canRedoTextAtom,
historyAtom: textHistoryAtom,
} = atomWithHistory('こんにちは');
typescript// 履歴情報を表示するコンポーネント
function HistoryViewer() {
const [history] = useAtom(textHistoryAtom);
return (
<div>
<h3>履歴状況</h3>
<p>過去の操作数: {history.past.length}</p>
<p>未来の操作数: {history.future.length}</p>
{history.past.length > 0 && (
<details>
<summary>過去の操作履歴</summary>
<ul>
{history.past.slice(-5).map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</details>
)}
</div>
);
}
typescript// メインのエディタコンポーネント
function TextEditor() {
const [text] = useAtom(textAtom)
const [, setText] = useAtom(setTextAtom)
const [, undo] = useAtom(undoTextAtom)
const [, redo] = useAtom(redoTextAtom)
const [canUndo] = useAtom(canUndoTextAtom)
const [canRedo] = useAtom(canRedoTextAtom)
typescript// キーボードショートカットの処理
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.metaKey || e.ctrlKey) {
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault();
undo();
} else if (e.key === 'z' && e.shiftKey) {
e.preventDefault();
redo();
}
}
};
typescript return (
<div>
<h2>履歴付きテキストエディタ</h2>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
rows={10}
cols={50}
placeholder="ここにテキストを入力してください..."
/>
<div>
<button onClick={undo} disabled={!canUndo}>
Undo (Ctrl+Z)
</button>
<button onClick={redo} disabled={!canRedo}>
Redo (Ctrl+Shift+Z)
</button>
</div>
<HistoryViewer />
</div>
)
}
複雑なオブジェクト状態の履歴管理
単純な値だけでなく、複雑なオブジェクトの履歴管理も可能です。
typescript// ユーザープロフィール用の型定義
interface UserProfile {
name: string;
email: string;
age: number;
preferences: {
theme: 'light' | 'dark';
language: string;
};
}
typescript// プロフィール用の履歴付きatom
const {
valueAtom: profileAtom,
updateAtom: setProfileAtom,
undoAtom: undoProfileAtom,
redoAtom: redoProfileAtom,
canUndoAtom: canUndoProfileAtom,
canRedoAtom: canRedoProfileAtom,
} = atomWithHistory<UserProfile>({
name: '太郎',
email: 'taro@example.com',
age: 25,
preferences: {
theme: 'light',
language: 'ja',
},
});
typescript// プロフィール編集コンポーネント
function ProfileEditor() {
const [profile] = useAtom(profileAtom)
const [, setProfile] = useAtom(setProfileAtom)
const [, undo] = useAtom(undoProfileAtom)
const [, redo] = useAtom(redoProfileAtom)
// 名前を更新する関数
const updateName = (newName: string) => {
setProfile({
...profile,
name: newName
})
}
typescript // テーマを切り替える関数
const toggleTheme = () => {
setProfile({
...profile,
preferences: {
...profile.preferences,
theme: profile.preferences.theme === 'light' ? 'dark' : 'light'
}
})
}
return (
<div>
<h2>プロフィール編集</h2>
<input
value={profile.name}
onChange={(e) => updateName(e.target.value)}
placeholder="名前"
/>
<button onClick={toggleTheme}>
テーマ: {profile.preferences.theme}
</button>
<div>
<button onClick={undo}>Undo</button>
<button onClick={redo}>Redo</button>
</div>
</div>
)
}
パフォーマンス最適化のテクニック
履歴サイズの制限
メモリ使用量を制御するために、履歴のサイズを制限しましょう。
typescriptfunction atomWithLimitedHistory<T>(
initialValue: T,
maxHistorySize: number = 50
) {
const historyAtom = atom<HistoryState<T>>({
past: [],
present: initialValue,
future: []
})
typescriptconst updateAtom = atom(null, (get, set, newValue: T) => {
const currentHistory = get(historyAtom);
let newPast = [
...currentHistory.past,
currentHistory.present,
];
// 履歴サイズの制限
if (newPast.length > maxHistorySize) {
newPast = newPast.slice(-maxHistorySize);
}
set(historyAtom, {
past: newPast,
present: newValue,
future: [],
});
});
デバウンス機能の実装
頻繁な更新によるパフォーマンス低下を防ぐため、デバウンス機能を実装します。
typescriptimport { atom } from 'jotai'
import { debounce } from 'lodash-es'
function atomWithDebouncedHistory<T>(
initialValue: T,
debounceMs: number = 300
) {
const historyAtom = atom<HistoryState<T>>({
past: [],
present: initialValue,
future: []
})
typescript// デバウンスされた更新関数
const debouncedUpdate = debounce(
(set: any, newValue: T) => {
const currentHistory = get(historyAtom);
set(historyAtom, {
past: [
...currentHistory.past,
currentHistory.present,
],
present: newValue,
future: [],
});
},
debounceMs
);
typescript const updateAtom = atom(
null,
(get, set, newValue: T) => {
// 即座に現在値を更新
const currentHistory = get(historyAtom)
set(historyAtom, {
...currentHistory,
present: newValue
})
// 履歴への追加はデバウンス
debouncedUpdate(set, newValue)
}
)
return { historyAtom, updateAtom /* ... */ }
}
メモリ効率的な履歴管理
大きなオブジェクトの場合、差分のみを保存することでメモリ使用量を削減できます。
typescriptimport { produce, enablePatches, applyPatches } from 'immer'
enablePatches()
function atomWithPatchHistory<T>(initialValue: T) {
// パッチ履歴を管理
const patchHistoryAtom = atom<{
past: any[][] // Immerのパッチ配列
present: T
future: any[][]
}>({
past: [],
present: initialValue,
future: []
})
typescript const updateAtom = atom(
null,
(get, set, updater: (draft: T) => void) => {
const current = get(patchHistoryAtom)
let patches: any[]
// Immerで変更を追跡
const newState = produce(
current.present,
updater,
(p) => { patches = p }
)
typescript set(patchHistoryAtom, {
past: [...current.past, patches],
present: newState,
future: []
})
}
)
return { patchHistoryAtom, updateAtom /* ... */ }
}
パフォーマンス最適化の比較
手法 | メモリ使用量 | 実装難易度 | 適用場面 |
---|---|---|---|
スナップショット | 高 | 低 | 小さなオブジェクト |
パッチベース | 低 | 中 | 大きなオブジェクト |
コマンドパターン | 中 | 高 | 複雑な操作 |
最適化手法の選択フローを図で示すと以下のようになります。
mermaidflowchart TD
start[状態管理の実装開始] --> size{オブジェクトサイズ}
size -->|小| simple[スナップショット方式]
size -->|大| complex{操作の複雑さ}
complex -->|単純| patch[パッチベース]
complex -->|複雑| command[コマンドパターン]
simple --> impl[実装]
patch --> impl
command --> impl
この図は、状態のサイズや操作の複雑さに応じて、適切な履歴管理手法を選択するためのガイドラインを示しています。
まとめ
Jotai を使用した Undo/Redo 機能付き状態管理について、基礎から実装まで詳しく解説してきました。
Jotai の原子的アプローチにより、従来の状態管理ライブラリと比較して、より柔軟で効率的な履歴管理が実現できます。特に、atom の組み合わせによる段階的な機能構築や、コマンドパターンの活用により、保守性の高いコードが書けるのが大きな魅力ですね。
実装時のポイントとして、パフォーマンス最適化は必須です。履歴サイズの制限、デバウンス機能、そしてパッチベースの履歴管理など、アプリケーションの要件に応じて適切な手法を選択することが重要でしょう。
今回ご紹介した実装例を参考に、ぜひ皆さんのプロジェクトでも Jotai を活用した履歴管理機能を試してみてください。
関連リンク
- article
Undo/Redo を備えた履歴つき状態管理を Jotai で設計する
- article
BroadcastChannel でタブ間同期:Jotai 状態をリアルタイムで共有する
- article
jotai IndexedDB・localForage と連携した大容量永続化パターン(atom を超えて)
- article
jotai/optics で深いネストを安全に操作する実践ガイド
- article
React Server Components 時代に Jotai はどう進化する?サーバーとクライアントをまたぐ状態管理の未来
- article
Jotai を 120%活用するユーティリティライブラリ(jotai-immer, jotai-xstate, jotai-form)の世界
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来