T-CREATOR

Zustand で実装する Undo Redo 機能:履歴管理の実用テクニック

Zustand で実装する Undo Redo 機能:履歴管理の実用テクニック

現代の Web アプリケーション開発において、ユーザビリティを向上させる機能として「元に戻す(Undo)」と「やり直し(Redo)」機能は欠かせない存在となっています。テキストエディタ、描画アプリ、フォーム編集など、様々な場面でユーザーの操作ミスをカバーし、安心してアプリケーションを使える環境を提供してくれます。

特に Zustand を使った状態管理では、シンプルな設計思想を活かしながら効率的に Undo/Redo 機能を実装できるのが大きな魅力です。複雑なミドルウェアやボイラープレートコードを書くことなく、直感的で理解しやすい履歴管理システムを構築できるでしょう。

今回は、Zustand を使った Undo/Redo 機能の実装テクニックを、基礎から応用まで幅広くご紹介いたします。メモリ効率やパフォーマンスも考慮した実用的な手法を身につけることで、ユーザーに愛されるアプリケーション作りの一歩を踏み出しましょう。

履歴管理の基本設計

履歴管理アーキテクチャの考え方

Undo/Redo 機能を実装する上で最も重要なのは、「どのような形で状態の履歴を保存するか」という設計方針です。Zustand では、状態全体のスナップショットを配列で管理する手法が効果的でしょう。

履歴管理の基本構造は以下の要素で構成されます。

#要素役割重要度
1past過去の状態を保存する配列
2present現在の状態
3future取り消された状態を保存する配列
4canUndoUndo 操作が可能かの判定フラグ
5canRedoRedo 操作が可能かの判定フラグ

基本的な履歴ストア設計

まず、履歴管理機能を持つ Zustand ストアの基本型定義から始めましょう。

typescriptinterface HistoryState<T> {
  past: T[];
  present: T;
  future: T[];
  canUndo: boolean;
  canRedo: boolean;

  // アクション
  updateState: (newState: T) => void;
  undo: () => void;
  redo: () => void;
  clearHistory: () => void;
}

この設計により、任意の状態型 T に対して履歴管理機能を提供できます。ジェネリクスを活用することで、再利用性の高い履歴管理システムを構築できるのがポイントですね。

履歴更新のタイミング制御

状態更新のたびに履歴を保存すると、パフォーマンスやメモリ使用量の問題が発生する可能性があります。そこで、履歴更新のタイミングを制御する仕組みが必要です。

typescriptinterface HistoryOptions {
  maxHistorySize?: number; // 履歴の最大保存数
  shouldSaveHistory?: (
    prevState: any,
    nextState: any
  ) => boolean; // 履歴保存の条件
  debounceMs?: number; // 履歴保存の遅延時間
}

これらのオプションを活用することで、アプリケーションの性質に応じた最適な履歴管理を実現できるでしょう。

基本的な Undo/Redo 実装

シンプルなカウンターアプリでの実装

最初に、基本的なカウンターアプリを例に Undo/Redo 機能を実装してみましょう。この例では、履歴管理の核となる概念を理解できます。

typescriptimport { create } from 'zustand';

interface CounterState {
  value: number;
}

interface CounterStore extends HistoryState<CounterState> {
  increment: () => void;
  decrement: () => void;
  setValue: (value: number) => void;
}

const useCounterStore = create<CounterStore>(
  (set, get) => ({
    // 履歴管理の初期状態
    past: [],
    present: { value: 0 },
    future: [],
    canUndo: false,
    canRedo: false,

    // 状態更新ヘルパー
    updateState: (newState: CounterState) => {
      const { past, present } = get();

      set({
        past: [...past, present],
        present: newState,
        future: [], // 新しい操作により、未来の履歴はクリア
        canUndo: true,
        canRedo: false,
      });
    },

    // Undo操作
    undo: () => {
      const { past, present, future } = get();

      if (past.length === 0) return;

      const previousState = past[past.length - 1];
      const newPast = past.slice(0, past.length - 1);

      set({
        past: newPast,
        present: previousState,
        future: [present, ...future],
        canUndo: newPast.length > 0,
        canRedo: true,
      });
    },

    // Redo操作
    redo: () => {
      const { past, present, future } = get();

      if (future.length === 0) return;

      const nextState = future[0];
      const newFuture = future.slice(1);

      set({
        past: [...past, present],
        present: nextState,
        future: newFuture,
        canUndo: true,
        canRedo: newFuture.length > 0,
      });
    },

    // 履歴クリア
    clearHistory: () => {
      set({
        past: [],
        future: [],
        canUndo: false,
        canRedo: false,
      });
    },

    // カウンター操作
    increment: () => {
      const { present, updateState } = get();
      updateState({ value: present.value + 1 });
    },

    decrement: () => {
      const { present, updateState } = get();
      updateState({ value: present.value - 1 });
    },

    setValue: (value: number) => {
      const { updateState } = get();
      updateState({ value });
    },
  })
);

React コンポーネントでの使用例

作成したストアを React コンポーネントで使用する例をご紹介します。

typescriptimport React from 'react';

const CounterWithHistory: React.FC = () => {
  const {
    present,
    canUndo,
    canRedo,
    increment,
    decrement,
    setValue,
    undo,
    redo,
    clearHistory,
  } = useCounterStore();

  return (
    <div className='counter-container'>
      <h2>カウンター: {present.value}</h2>

      <div className='counter-controls'>
        <button onClick={increment}>+1</button>
        <button onClick={decrement}>-1</button>
        <input
          type='number'
          onChange={(e) =>
            setValue(parseInt(e.target.value) || 0)
          }
          placeholder='直接入力'
        />
      </div>

      <div className='history-controls'>
        <button onClick={undo} disabled={!canUndo}>
          元に戻す (Ctrl+Z)
        </button>
        <button onClick={redo} disabled={!canRedo}>
          やり直し (Ctrl+Y)
        </button>
        <button onClick={clearHistory}>履歴をクリア</button>
      </div>
    </div>
  );
};

この実装により、ユーザーは安心してカウンターを操作でき、間違った操作も簡単に取り消すことができるようになります。

効率的な履歴管理テクニック

メモリ最適化による履歴サイズ制限

実際のアプリケーションでは、無制限に履歴を保存するとメモリ不足の原因となります。履歴サイズを制限する効率的な手法を実装しましょう。

typescriptconst createHistoryStore = <T>(
  initialState: T,
  options: HistoryOptions = {}
) => {
  const {
    maxHistorySize = 50,
    shouldSaveHistory = () => true,
    debounceMs = 0,
  } = options;

  return create<HistoryState<T>>((set, get) => {
    let debounceTimer: NodeJS.Timeout | null = null;

    const saveToHistory = (newState: T) => {
      const { past, present } = get();

      // 履歴保存の条件チェック
      if (!shouldSaveHistory(present, newState)) {
        set({ present: newState });
        return;
      }

      const updateHistory = () => {
        const newPast = [...past, present];

        // 履歴サイズ制限の適用
        if (newPast.length > maxHistorySize) {
          newPast.splice(
            0,
            newPast.length - maxHistorySize
          );
        }

        set({
          past: newPast,
          present: newState,
          future: [],
          canUndo: true,
          canRedo: false,
        });
      };

      // デバウンス処理
      if (debounceMs > 0) {
        if (debounceTimer) clearTimeout(debounceTimer);
        debounceTimer = setTimeout(
          updateHistory,
          debounceMs
        );
      } else {
        updateHistory();
      }
    };

    return {
      past: [],
      present: initialState,
      future: [],
      canUndo: false,
      canRedo: false,

      updateState: saveToHistory,

      // Undo/Redo実装は前述と同様
      undo: () => {
        /* 実装省略 */
      },
      redo: () => {
        /* 実装省略 */
      },
      clearHistory: () => {
        /* 実装省略 */
      },
    };
  });
};

条件付き履歴保存の実装

すべての状態変更を履歴に保存する必要はありません。意味のある変更のみを記録することで、ユーザビリティとパフォーマンスの両方を向上させられます。

typescript// テキストエディタでの例
const shouldSaveTextHistory = (
  prevState: TextState,
  nextState: TextState
) => {
  // 1文字以下の変更は履歴に保存しない
  if (
    Math.abs(
      prevState.text.length - nextState.text.length
    ) <= 1
  ) {
    return false;
  }

  // 時間間隔が短すぎる場合は保存しない
  if (
    nextState.lastModified - prevState.lastModified <
    1000
  ) {
    return false;
  }

  return true;
};

const useTextStore = createHistoryStore(
  { text: '', lastModified: Date.now() },
  {
    shouldSaveHistory: shouldSaveTextHistory,
    debounceMs: 500,
    maxHistorySize: 100,
  }
);

この手法により、ユーザーの編集作業の流れを尊重しながら、効率的な履歴管理を実現できるでしょう。

メモリ使用量の監視とガベージコレクション

大きな状態オブジェクトを扱う場合は、メモリ使用量の監視も重要です。

typescriptinterface MemoryMonitor {
  getCurrentMemoryUsage: () => number;
  shouldTriggerCleanup: () => boolean;
  performCleanup: () => void;
}

const createMemoryAwareHistoryStore = <T>(
  initialState: T,
  memoryMonitor?: MemoryMonitor
) => {
  return create<HistoryState<T>>((set, get) => ({
    // 基本実装...

    updateState: (newState: T) => {
      // メモリ監視とクリーンアップ
      if (memoryMonitor?.shouldTriggerCleanup()) {
        const { past } = get();
        const cleanedPast = past.slice(-10); // 最新10件のみ保持

        set((state) => ({
          ...state,
          past: cleanedPast,
        }));

        memoryMonitor.performCleanup();
      }

      // 通常の状態更新処理
      // ...
    },
  }));
};

複雑な状態での履歴管理

ネストオブジェクトの深いコピー対策

複雑なネスト構造を持つ状態オブジェクトでは、参照の共有を避けるための深いコピーが必要です。しかし、毎回深いコピーを行うとパフォーマンスの問題が発生するため、効率的な手法を採用しましょう。

typescriptimport { produce } from 'immer';

interface ComplexState {
  user: {
    profile: {
      name: string;
      email: string;
      preferences: {
        theme: string;
        notifications: boolean;
        languages: string[];
      };
    };
    permissions: string[];
  };
  documents: Array<{
    id: string;
    title: string;
    content: string;
    metadata: Record<string, any>;
  }>;
}

const useComplexStore = create<HistoryState<ComplexState>>(
  (set, get) => ({
    // 初期状態
    past: [],
    present: {
      user: {
        profile: {
          name: '',
          email: '',
          preferences: {
            theme: 'light',
            notifications: true,
            languages: ['ja'],
          },
        },
        permissions: [],
      },
      documents: [],
    },
    future: [],
    canUndo: false,
    canRedo: false,

    // Immerを使用した効率的な状態更新
    updateState: (
      updater: (draft: ComplexState) => void
    ) => {
      const { past, present } = get();

      const nextState = produce(present, updater);

      // 実際に変更があった場合のみ履歴に保存
      if (nextState !== present) {
        set({
          past: [...past, present],
          present: nextState,
          future: [],
          canUndo: true,
          canRedo: false,
        });
      }
    },

    // 具体的な更新操作の例
    updateUserName: (name: string) => {
      const { updateState } = get();
      updateState((draft) => {
        draft.user.profile.name = name;
      });
    },

    addDocument: (
      document: Omit<ComplexState['documents'][0], 'id'>
    ) => {
      const { updateState } = get();
      updateState((draft) => {
        draft.documents.push({
          ...document,
          id: crypto.randomUUID(),
        });
      });
    },

    // その他の実装...
  })
);

配列操作の効率的な差分管理

配列の要素追加、削除、並び替えなどの操作では、インデックスベースの差分管理が効果的です。

typescriptinterface ListItem {
  id: string;
  text: string;
  completed: boolean;
}

interface TodoState {
  items: ListItem[];
  filter: 'all' | 'active' | 'completed';
}

const useTodoStore = create<HistoryState<TodoState>>(
  (set, get) => ({
    // 基本状態...

    // 配列操作の最適化された実装
    moveItem: (fromIndex: number, toIndex: number) => {
      const { present, updateState } = get();

      if (fromIndex === toIndex) return;

      updateState((draft) => {
        const [movedItem] = draft.items.splice(
          fromIndex,
          1
        );
        draft.items.splice(toIndex, 0, movedItem);
      });
    },

    bulkUpdate: (
      updates: Array<{
        id: string;
        changes: Partial<ListItem>;
      }>
    ) => {
      const { updateState } = get();

      updateState((draft) => {
        updates.forEach(({ id, changes }) => {
          const item = draft.items.find(
            (item) => item.id === id
          );
          if (item) {
            Object.assign(item, changes);
          }
        });
      });
    },

    // 選択的な履歴保存(フィルター変更は履歴に含めない)
    setFilter: (filter: TodoState['filter']) => {
      set((state) => ({
        ...state,
        present: {
          ...state.present,
          filter,
        },
      }));
    },
  })
);

部分的な状態更新による最適化

大きな状態オブジェクトの一部だけが変更される場合、部分的な履歴保存により効率化を図れます。

typescriptinterface PartialHistoryState<T> {
  past: Array<{
    path: string[];
    value: any;
    fullState?: T; // 重要な変更時のみフル状態を保存
  }>;
  present: T;
  future: Array<{
    path: string[];
    value: any;
    fullState?: T;
  }>;
}

const createPartialHistoryStore = <
  T extends Record<string, any>
>(
  initialState: T
) => {
  return create<PartialHistoryState<T>>((set, get) => ({
    past: [],
    present: initialState,
    future: [],

    updatePartial: (
      path: string[],
      value: any,
      isImportant = false
    ) => {
      const { past, present } = get();

      const historyEntry = {
        path,
        value: getValueAtPath(present, path), // 変更前の値を保存
        fullState: isImportant ? present : undefined,
      };

      const nextState = setValueAtPath(
        present,
        path,
        value
      );

      set({
        past: [...past, historyEntry],
        present: nextState,
        future: [],
      });
    },
  }));
};

// ヘルパー関数
const getValueAtPath = (obj: any, path: string[]): any => {
  return path.reduce((current, key) => current?.[key], obj);
};

const setValueAtPath = (
  obj: any,
  path: string[],
  value: any
): any => {
  return produce(obj, (draft) => {
    let current = draft;
    for (let i = 0; i < path.length - 1; i++) {
      current = current[path[i]];
    }
    current[path[path.length - 1]] = value;
  });
};

実用的な最適化手法

スナップショット間隔の動的調整

ユーザーの操作パターンに応じて、スナップショット保存の間隔を動的に調整する手法をご紹介します。

typescriptinterface AdaptiveHistoryOptions {
  minInterval: number; // 最小間隔(ミリ秒)
  maxInterval: number; // 最大間隔(ミリ秒)
  adaptationFactor: number; // 調整係数
}

class AdaptiveHistoryManager<T> {
  private lastSaveTime = 0;
  private currentInterval: number;
  private operationFrequency = 0;
  private frequencyHistory: number[] = [];

  constructor(private options: AdaptiveHistoryOptions) {
    this.currentInterval = options.minInterval;
  }

  shouldSaveHistory(newState: T): boolean {
    const now = Date.now();
    const timeSinceLastSave = now - this.lastSaveTime;

    // 操作頻度の計算
    this.operationFrequency++;
    if (this.frequencyHistory.length >= 10) {
      this.frequencyHistory.shift();
    }
    this.frequencyHistory.push(this.operationFrequency);

    // 間隔の動的調整
    const avgFrequency =
      this.frequencyHistory.reduce((a, b) => a + b, 0) /
      this.frequencyHistory.length;

    if (avgFrequency > 5) {
      // 高頻度操作時は間隔を長くする
      this.currentInterval = Math.min(
        this.options.maxInterval,
        this.currentInterval * this.options.adaptationFactor
      );
    } else {
      // 低頻度操作時は間隔を短くする
      this.currentInterval = Math.max(
        this.options.minInterval,
        this.currentInterval / this.options.adaptationFactor
      );
    }

    if (timeSinceLastSave >= this.currentInterval) {
      this.lastSaveTime = now;
      this.operationFrequency = 0;
      return true;
    }

    return false;
  }
}

const useAdaptiveHistoryStore = <T>(initialState: T) => {
  const historyManager = new AdaptiveHistoryManager<T>({
    minInterval: 100,
    maxInterval: 2000,
    adaptationFactor: 1.5,
  });

  return create<HistoryState<T>>((set, get) => ({
    // 基本実装...

    updateState: (newState: T) => {
      if (historyManager.shouldSaveHistory(newState)) {
        const { past, present } = get();
        set({
          past: [...past, present],
          present: newState,
          future: [],
          canUndo: true,
          canRedo: false,
        });
      } else {
        set({ present: newState });
      }
    },
  }));
};

メモリ効率的な deep copy の回避

不要な深いコピーを避けるため、構造共有(structural sharing)を活用した最適化手法を実装します。

typescriptimport { current } from 'immer';

interface OptimizedHistoryEntry<T> {
  state: T;
  timestamp: number;
  checksum: string; // 状態の変更検出用
}

const createOptimizedHistoryStore = <T>(
  initialState: T
) => {
  const generateChecksum = (state: T): string => {
    return JSON.stringify(state)
      .split('')
      .reduce((hash, char) => {
        return (
          ((hash << 5) - hash + char.charCodeAt(0)) &
          0xffffffff
        );
      }, 0)
      .toString(16);
  };

  return create<{
    past: OptimizedHistoryEntry<T>[];
    present: T;
    future: OptimizedHistoryEntry<T>[];
    canUndo: boolean;
    canRedo: boolean;
    updateState: (updater: (draft: T) => void) => void;
    undo: () => void;
    redo: () => void;
  }>((set, get) => ({
    past: [],
    present: initialState,
    future: [],
    canUndo: false,
    canRedo: false,

    updateState: (updater) => {
      const { past, present } = get();

      const nextState = produce(present, updater);
      const nextChecksum = generateChecksum(nextState);
      const presentChecksum = generateChecksum(present);

      // チェックサムで実際の変更を検出
      if (nextChecksum === presentChecksum) {
        return; // 変更なしの場合は何もしない
      }

      const historyEntry: OptimizedHistoryEntry<T> = {
        state: current(present), // Immerのcurrentで最適化されたコピー
        timestamp: Date.now(),
        checksum: presentChecksum,
      };

      set({
        past: [...past, historyEntry],
        present: nextState,
        future: [],
        canUndo: true,
        canRedo: false,
      });
    },

    undo: () => {
      const { past, present, future } = get();

      if (past.length === 0) return;

      const previousEntry = past[past.length - 1];
      const newPast = past.slice(0, -1);

      const currentEntry: OptimizedHistoryEntry<T> = {
        state: current(present),
        timestamp: Date.now(),
        checksum: generateChecksum(present),
      };

      set({
        past: newPast,
        present: previousEntry.state,
        future: [currentEntry, ...future],
        canUndo: newPast.length > 0,
        canRedo: true,
      });
    },

    redo: () => {
      const { past, present, future } = get();

      if (future.length === 0) return;

      const nextEntry = future[0];
      const newFuture = future.slice(1);

      const currentEntry: OptimizedHistoryEntry<T> = {
        state: current(present),
        timestamp: Date.now(),
        checksum: generateChecksum(present),
      };

      set({
        past: [...past, currentEntry],
        present: nextEntry.state,
        future: newFuture,
        canUndo: true,
        canRedo: newFuture.length > 0,
      });
    },
  }));
};

バックグラウンドでの履歴圧縮

長時間動作するアプリケーションでは、バックグラウンドでの履歴圧縮により、メモリ使用量を効率的に管理できます。

typescriptinterface CompressionOptions {
  compressionInterval: number; // 圧縮実行間隔
  maxUncompressedEntries: number; // 非圧縮エントリの最大数
  compressionRatio: number; // 圧縮比率
}

class HistoryCompressor<T> {
  private compressionTimer: NodeJS.Timeout | null = null;

  constructor(
    private store: any,
    private options: CompressionOptions
  ) {
    this.startCompression();
  }

  private startCompression() {
    this.compressionTimer = setInterval(() => {
      this.compressHistory();
    }, this.options.compressionInterval);
  }

  private compressHistory() {
    const state = this.store.getState();
    const { past } = state;

    if (
      past.length <= this.options.maxUncompressedEntries
    ) {
      return;
    }

    // 古い履歴を間引く(重要度に基づいて)
    const compressedPast = this.selectImportantEntries(
      past,
      Math.floor(
        past.length * this.options.compressionRatio
      )
    );

    this.store.setState({
      ...state,
      past: compressedPast,
    });
  }

  private selectImportantEntries(
    entries: any[],
    targetCount: number
  ): any[] {
    // 時間間隔と変更の大きさを考慮した重要度計算
    const scored = entries.map((entry, index) => ({
      entry,
      score: this.calculateImportance(
        entry,
        index,
        entries
      ),
    }));

    return scored
      .sort((a, b) => b.score - a.score)
      .slice(0, targetCount)
      .map((item) => item.entry)
      .sort((a, b) => a.timestamp - b.timestamp);
  }

  private calculateImportance(
    entry: any,
    index: number,
    entries: any[]
  ): number {
    let score = 0;

    // 最新の履歴ほど重要
    score += (entries.length - index) * 0.3;

    // 時間間隔が長い履歴は重要
    if (index > 0) {
      const timeDiff =
        entry.timestamp - entries[index - 1].timestamp;
      score += Math.min(timeDiff / 1000, 10) * 0.2;
    }

    // 状態変更が大きい履歴は重要
    if (entry.changeSize) {
      score += entry.changeSize * 0.5;
    }

    return score;
  }

  destroy() {
    if (this.compressionTimer) {
      clearInterval(this.compressionTimer);
    }
  }
}

まとめ

Zustand を使った Undo/Redo 機能の実装は、シンプルな設計思想を保ちながら、高度な履歴管理システムを構築できる魅力的なアプローチです。今回ご紹介した実装技術を活用することで、ユーザビリティとパフォーマンスを両立させた実用的なアプリケーションを開発できるでしょう。

特に重要なポイントとして、以下の点を心に留めておいていただければと思います。

まず、履歴管理の基本設計では、past・present・future の 3 つの配列による状態管理が核となります。この構造により、直感的で理解しやすい履歴システムを構築できますね。

効率的な履歴管理テクニックについては、メモリ最適化と条件付き履歴保存が重要です。すべての変更を記録するのではなく、意味のある変更のみを選別することで、ユーザーエクスペリエンスの向上につながります。

複雑な状態での履歴管理では、Immer ライブラリとの組み合わせが効果的でした。ネストしたオブジェクトや配列操作も、構造共有により効率的に処理できるのが大きなメリットです。

そして実用的な最適化手法として、動的な間隔調整やバックグラウンド圧縮など、長期運用を見据えた技術をご紹介いたしました。これらの手法により、メモリ使用量を抑制しながら、快適な操作感を維持できるでしょう。

Undo/Redo 機能は、ユーザーが安心してアプリケーションを使える環境を提供する重要な要素です。Zustand の柔軟性を活かし、アプリケーションの特性に応じた最適な履歴管理システムを構築して、ユーザーに愛される製品作りに活用していただければ幸いです。

関連リンク