T-CREATOR

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

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 を活用した履歴管理機能を試してみてください。

関連リンク