Reactで、元に戻す・やり直すの機能を作る方法
元に戻す・やり直すは、状態を「過去の値の並び」「今の値」「やり直せる値の並び」の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、スマートフォンのブラウザも試していません。

過去・現在・未来に分けて持つフック
履歴の形は、Redux の公式ドキュメントにある、元に戻す機能の作り方のページと同じです。past に過去の値を古い順に並べ、present に今の値を、future にやり直せる値を持ちます。
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 に「前の配列から新しい配列を作る関数」を渡すだけです。
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 にしました。
<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になり、ボタンが押せなくなりました。
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件は残りました。
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 は、入力した文字を戻すブラウザの機能に任せます。
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 が届き、やり直しが効きました。
[ページ] 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 を押すと、そのチェックが元に戻りました。
フォーカス: 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 のままで、ブラウザの機能が働いています。
入力欄: 洗濯をする
入力欄に「洗濯をする」と入力: [[ ] 牛乳を買う, [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 に持ち、今の値の更新用の関数の中で履歴を積む書き方です。
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回呼ばれないからにすぎません。更新用の関数は純粋でなければならないので、本番で動いて見えても、この書き方は使いません。
--- うまくいかない書き方(本番ビルド、StrictMode あり) ---
更新用の関数の中で履歴を積む: 1回追加 → 項目 1 件、履歴 1 件
更新用の関数の中で履歴を積む: 2回追加 → 項目 2 件、履歴 2 件
...
--- うまくいかない書き方(開発ビルド、StrictMode あり) ---
...
更新用の関数の中で履歴を積む: 1回追加 → 項目 1 件、履歴 2 件
更新用の関数の中で履歴を積む: 2回追加 → 項目 2 件、履歴 4 件
2つ目は、配列はコピーしても、中のオブジェクトを書き換える書き方です。
todos.set((prev) => {
prev[0].done = !prev[0].done; // 前の値のオブジェクトを直接書き換えている
return [...prev];
});
配列のコピーは浅いので、新しい配列と前の配列は同じオブジェクトを指しています。このことは、公式ドキュメントの、state の配列を更新するページにも書かれています。past に入っている前の値まで書き換わるので、本番ビルドでは、切り替えてから元に戻しても done は true のままでした。開発ビルドでは書き換えが2回起きて元に戻り、クリックしても done が変わりませんでした。
--- うまくいかない書き方(本番ビルド、StrictMode あり) ---
...
オブジェクトを書き換える: 最初 → done false、履歴 0 件
オブジェクトを書き換える: 切り替え → done true、履歴 1 件
オブジェクトを書き換える: 元に戻す → done true、履歴 0 件
--- うまくいかない書き方(開発ビルド、StrictMode あり) ---
...
オブジェクトを書き換える: 最初 → done false、履歴 0 件
オブジェクトを書き換える: 切り替え → done false、履歴 1 件
オブジェクトを書き換える: 元に戻す → done false、履歴 0 件
履歴は reducer の中だけで積み、値を変えるときは map やスプレッド構文で新しいオブジェクトを作ります。前の値を書き換えなければ、past に残した値はそのまま元に戻す先として使えます。





