Reactで、元に戻す・やり直すの機能を作る方法

​​React​Playwright,StrictMode,TypeScript,useReducer

元に戻す・やり直すは、状態を「過去の値の並び」「今の値」「やり直せる値の並び」の3つに分けて持てば、ライブラリを使わずに作れます。変更のたびに今の値を過去へ積み、元に戻すときは過去から1つ取り出します。この3つを useReducer で1つの state にまとめると、追加・チェック・削除のどの操作でも、履歴を更新する処理は1つの reducer にまとまります。

ここでは、どんな値にも使えるフック useUndoable と、Ctrl+Z などのキーで元に戻す・やり直すを呼び出すフック useUndoShortcuts を作り、やることリストで動かしました。あわせて、履歴を別の state に積む書き方や、配列の中のオブジェクトを書き換える書き方で、履歴がどう壊れるかも確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 で、Playwright でクリックとキー入力をして確かめました。Mac の Command キーは、Playwright で Meta キーを押したときの動きだけを確かめ、実際の Mac では試していません。Firefox と Safari、スマートフォンのブラウザも試していません。

やることを2件追加し、チェックと削除をしたあと、元に戻すを2回、やり直すを1回押し、最後に新しく追加すると、やり直すの回数が0になる
8回の操作で、リストと「元に戻す」「やり直す」の回数が変わる様子。変わった行と回数だけピンクにしている(カーソルと操作の表示は GIF 用に重ねたもの。本文の出力とは別の操作の流れ)

過去・現在・未来に分けて持つフック

履歴の形は、Redux の公式ドキュメントにある、元に戻す機能の作り方のページと同じです。past に過去の値を古い順に並べ、present に今の値を、future にやり直せる値を持ちます。

ts
import { useCallback, useReducer } from 'react';

type History<T> = { past: T[]; present: T; future: T[] };
type Action<T> =
  | { type: 'set'; next: (prev: T) => T; limit: number }
  | { type: 'undo' }
  | { type: 'redo' };

export function historyReducer<T>(state: History<T>, action: Action<T>): History<T> {
  switch (action.type) {
    case 'set': {
      const next = action.next(state.present);
      // 変わっていなければ、履歴を積まない
      if (Object.is(next, state.present)) return state;
      return {
        // 古いものから捨てて、limit 件までにする
        past: [...state.past, state.present].slice(-action.limit),
        present: next,
        // 新しい変更をしたら、やり直しの履歴は消す
        future: [],
      };
    }
    case 'undo': {
      if (state.past.length === 0) return state;
      return {
        past: state.past.slice(0, -1),
        present: state.past[state.past.length - 1],
        future: [state.present, ...state.future],
      };
    }
    case 'redo': {
      if (state.future.length === 0) return state;
      return {
        past: [...state.past, state.present],
        present: state.future[0],
        future: state.future.slice(1),
      };
    }
  }
}

export function useUndoable<T>(initial: T, limit = 100) {
  const [history, dispatch] = useReducer(historyReducer<T>, {
    past: [],
    present: initial,
    future: [],
  });

  const set = useCallback(
    (next: (prev: T) => T) => dispatch({ type: 'set', next, limit }),
    [limit],
  );
  const undo = useCallback(() => dispatch({ type: 'undo' }), []);
  const redo = useCallback(() => dispatch({ type: 'redo' }), []);

  return {
    value: history.present,
    set,
    undo,
    redo,
    undoCount: history.past.length,
    redoCount: history.future.length,
  };
}

元に戻すときは、past の最後の値を今の値にし、それまでの今の値を future の先頭に入れます。やり直すときは、その逆です。

新しい変更をしたときに future を空にするのは、元に戻したあとに別の変更をすると、その先の「やり直せる値」はもう今の値とつながらないからです。past は slice(-limit) で後ろから limit 件だけ残し、履歴がいくらでも増えないようにしています。

set には、新しい値ではなく、前の値から新しい値を作る関数を渡します。その関数は reducer の中で、今の値(present)を引数にして呼ばれます。

やることリストで使う

コンポーネントからは、useState の代わりに useUndoable を呼びます。追加・チェック・削除は、どれも todos.set に「前の配列から新しい配列を作る関数」を渡すだけです。

tsx
const todos = useUndoable<Todo[]>([], limit);

// 追加
todos.set((prev) => [...prev, { id, text, done: false }]);
// チェックの切り替え
todos.set((prev) =>
  prev.map((x) => (x.id === t.id ? { ...x, done: !x.done } : x)),
);
// 削除
todos.set((prev) => prev.filter((x) => x.id !== t.id));

元に戻せる回数・やり直せる回数が0のときは、それぞれのボタンを disabled にしました。

tsx
<button type="button" onClick={todos.undo} disabled={todos.undoCount === 0}>
  元に戻す(<span data-testid="undo-count">{todos.undoCount}</span>)
</button>
<button type="button" onClick={todos.redo} disabled={todos.redoCount === 0}>
  やり直す(<span data-testid="redo-count">{todos.redoCount}</span>)
</button>

3件追加し、チェックと削除をしてから、元に戻す・やり直すを押した結果です。最後に新しく追加すると、やり直せる回数が0になり、ボタンが押せなくなりました。

text
3件追加: [[ ] 牛乳を買う, [ ] 本を返す, [ ] 部屋を片付ける] 元に戻す 3 / やり直す 0
「本を返す」をチェック: [[ ] 牛乳を買う, [x] 本を返す, [ ] 部屋を片付ける] 元に戻す 4 / やり直す 0
「部屋を片付ける」を削除: [[ ] 牛乳を買う, [x] 本を返す] 元に戻す 5 / やり直す 0
元に戻す: [[ ] 牛乳を買う, [x] 本を返す, [ ] 部屋を片付ける] 元に戻す 4 / やり直す 1
元に戻す: [[ ] 牛乳を買う, [ ] 本を返す, [ ] 部屋を片付ける] 元に戻す 3 / やり直す 2
やり直す: [[ ] 牛乳を買う, [x] 本を返す, [ ] 部屋を片付ける] 元に戻す 4 / やり直す 1
新しく追加: [[ ] 牛乳を買う, [x] 本を返す, [ ] 部屋を片付ける, [ ] 花に水をやる] 元に戻す 5 / やり直す 0
やり直すボタンの disabled: true

履歴の上限も確かめました。limit を3にして5件追加すると、戻せるのは3回までで、最初の2件は残りました。

text
5件追加: [[ ] A, [ ] B, [ ] C, [ ] D, [ ] E] 元に戻す 3 / やり直す 0
元に戻すを4回押そうとした: [[ ] A, [ ] B] 元に戻す 0 / やり直す 3

Ctrl+Z などのキーボード操作で元に戻す・やり直す

キーボードでは、Ctrl+Z で元に戻し、Ctrl+Shift+Z か Ctrl+Y でやり直せるようにしました。Mac の Command キーが押されているかどうかは、metaKey で分かります。

ただし、文字を入力する欄にフォーカスがあるときは、何もしません。その欄の Ctrl+Z は、入力した文字を戻すブラウザの機能に任せます。

ts
import { useEffect } from 'react';

// 文字を入力する欄では、ブラウザの「元に戻す」に任せる
function isTextField(target: EventTarget | null) {
  if (!(target instanceof HTMLElement)) return false;
  if (target.isContentEditable || target instanceof HTMLTextAreaElement) return true;
  if (!(target instanceof HTMLInputElement)) return false;
  return !['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'color', 'file'].includes(target.type);
}

export function useUndoShortcuts(undo: () => void, redo: () => void) {
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (!(e.ctrlKey || e.metaKey) || e.altKey || isTextField(e.target)) return;
      const key = e.key.toLowerCase();
      if (key === 'z' && !e.shiftKey) {
        e.preventDefault();
        undo();
      } else if ((key === 'z' && e.shiftKey) || (key === 'y' && !e.shiftKey)) {
        e.preventDefault();
        redo();
      }
    }
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [undo, redo]);
}

チェックボックスも input 要素です。input かどうかだけで分けると、チェックした直後(フォーカスがチェックボックスにある間)は、ショートカットの処理が動かなくなります。そこで、type が文字を入力しないもの(checkbox など)は除きました。contenteditable の要素は isContentEditable で見分けます。

Shift を押しながら Z を押すと、e.key は大文字の Z になります。そのため、toLowerCase() で小文字にしてから比べています。

ページの本文にフォーカスがある状態で、キーを押した結果です。ログの [ページ] の行は、確かめるために別に付けたリスナーが、keydown のたびに e.key や defaultPrevented を書き出したものです。Playwright で、人が Shift を押しながら打ったときと同じく大文字の Z を送ると、key=Z が届き、やり直しが効きました。

text
  [ページ] keydown key=Control ctrl=true meta=false shift=false defaultPrevented=false
  [ページ] keydown key=z ctrl=true meta=false shift=false defaultPrevented=true
Control+z: [[ ] 牛乳を買う, [x] 本を返す, [ ] 部屋を片付ける] 元に戻す 4 / やり直す 1
  [ページ] keydown key=Control ctrl=true meta=false shift=false defaultPrevented=false
  [ページ] keydown key=Shift ctrl=true meta=false shift=true defaultPrevented=false
  [ページ] keydown key=Z ctrl=true meta=false shift=true defaultPrevented=true
Control+Shift+Z(大文字): [[ ] 牛乳を買う, [x] 本を返す, [ ] 部屋を片付ける, [ ] 花に水をやる] 元に戻す 5 / やり直す 0

Ctrl+Y、Meta+Z、Meta+Shift+Z でも同じように動きました(Meta+Shift+Z は、小文字の z で送った場合と、大文字の Z で送った場合の両方)。チェックボックスをクリックした直後に Ctrl+Z を押すと、そのチェックが元に戻りました。

text
フォーカス: INPUT type=checkbox
「牛乳を買う」をチェック: [[x] 牛乳を買う, [x] 本を返す, [ ] 部屋を片付ける, [ ] 花に水をやる] 元に戻す 6 / やり直す 0
  [ページ] keydown key=Control ctrl=true meta=false shift=false defaultPrevented=false
  [ページ] keydown key=z ctrl=true meta=false shift=false defaultPrevented=true
Control+z: [[ ] 牛乳を買う, [x] 本を返す, [ ] 部屋を片付ける, [ ] 花に水をやる] 元に戻す 5 / やり直す 1

入力欄に「洗濯をする」と打ってから Ctrl+Z を押すと、戻ったのは入力欄の文字だけで、リストの履歴は変わりませんでした。このとき defaultPrevented は false のままで、ブラウザの機能が働いています。

text
入力欄: 洗濯をする
入力欄に「洗濯をする」と入力: [[ ] 牛乳を買う, [x] 本を返す, [ ] 部屋を片付ける, [ ] 花に水をやる] 元に戻す 5 / やり直す 1
  [ページ] keydown key=Control ctrl=true meta=false shift=false defaultPrevented=false
  [ページ] keydown key=z ctrl=true meta=false shift=false defaultPrevented=false
入力欄: 洗濯をす
入力欄で Control+z: [[ ] 牛乳を買う, [x] 本を返す, [ ] 部屋を片付ける, [ ] 花に水をやる] 元に戻す 5 / やり直す 1

この検証では、Playwright で1文字ずつ入力したため、Ctrl+Z で戻ったのは最後の1文字でした。人がキーボードで打ったときに、どこまでまとめて戻るかは試していません。

履歴が壊れる2つの書き方

reducer は純粋な関数でなければなりません。useState の set 関数に渡す更新用の関数も、公式リファレンスで純粋な関数と決められています。開発中に StrictMode を使っていると、React は reducer を2回呼んで、純粋でないところを見つけやすくします。更新用の関数も同じように2回呼ばれます。

useUndoable の set に渡す関数も、reducer の中で呼ぶので、純粋でなければなりません。この2回呼ぶ動きは開発ビルドだけのものです。そのため、純粋でない書き方をすると、開発ビルドと本番ビルドで壊れ方が変わりました。

1つ目は、履歴を別の useState に持ち、今の値の更新用の関数の中で履歴を積む書き方です。

tsx
setTodos((prev) => {
  setPast((p) => [...p, prev]); // 更新用の関数の中で、別の state を更新している
  return [...prev, { id: prev.length + 1, text: `項目${prev.length + 1}`, done: false }];
});

本番ビルドでは1回の追加で履歴が1件増えましたが、StrictMode の開発ビルドでは2件ずつ増えました。更新用の関数が2回呼ばれるので、中の setPast も2回呼ばれたためです。本番ビルドで1件ずつ増えたのは、2回呼ばれないからにすぎません。更新用の関数は純粋でなければならないので、本番で動いて見えても、この書き方は使いません。

text
--- うまくいかない書き方(本番ビルド、StrictMode あり) ---
更新用の関数の中で履歴を積む: 1回追加 → 項目 1 件、履歴 1 件
更新用の関数の中で履歴を積む: 2回追加 → 項目 2 件、履歴 2 件
...
--- うまくいかない書き方(開発ビルド、StrictMode あり) ---
...
更新用の関数の中で履歴を積む: 1回追加 → 項目 1 件、履歴 2 件
更新用の関数の中で履歴を積む: 2回追加 → 項目 2 件、履歴 4 件

2つ目は、配列はコピーしても、中のオブジェクトを書き換える書き方です。

tsx
todos.set((prev) => {
  prev[0].done = !prev[0].done; // 前の値のオブジェクトを直接書き換えている
  return [...prev];
});

配列のコピーは浅いので、新しい配列と前の配列は同じオブジェクトを指しています。このことは、公式ドキュメントの、state の配列を更新するページにも書かれています。past に入っている前の値まで書き換わるので、本番ビルドでは、切り替えてから元に戻しても done は true のままでした。開発ビルドでは書き換えが2回起きて元に戻り、クリックしても done が変わりませんでした。

text
--- うまくいかない書き方(本番ビルド、StrictMode あり) ---
...
オブジェクトを書き換える: 最初 → done false、履歴 0 件
オブジェクトを書き換える: 切り替え → done true、履歴 1 件
オブジェクトを書き換える: 元に戻す → done true、履歴 0 件
--- うまくいかない書き方(開発ビルド、StrictMode あり) ---
...
オブジェクトを書き換える: 最初 → done false、履歴 0 件
オブジェクトを書き換える: 切り替え → done false、履歴 1 件
オブジェクトを書き換える: 元に戻す → done false、履歴 0 件
ポイント

履歴は reducer の中だけで積み、値を変えるときは map やスプレッド構文で新しいオブジェクトを作ります。前の値を書き換えなければ、past に残した値はそのまま元に戻す先として使えます。

​Posted by 86