しばしばある操作を戻る、進む(戻したのをさらに戻す)という機能が求められることがあります。Windows のテキストエディタならよく Ctrl+Z, Ctrl+Y で備わっているあれです。React でこれを実装する場合、ある一つの state で実装されている部分に実装するのは多少手間ですが、そこまで難しくありません。use-undo というライブラリが王道にありますし、以前自分が書いたチューリングマシンフックでもできます。
homerchen19/use-undo: React Hooks to implement Undo and Redo functionality
【React】チューリングマシン的なデータ構造とデータの扱いをフックにまとめる – 株式会社シーポイントラボ | 浜松のシステム・RTK-GNSS開発
state が単体であるのならば↑の様にできるのですが、時として多様な操作、データをまとめて戻る/進むを実装するべき時があります。画面上ではばらばらでもユーザーの操作は一つずつなので全てまとめて一つずつ戻る/進むをしたい、というやつです。
この多機能をまとめて戻る/進む実装方法の一つに Recoil のスナップショット機能の様な React 中の state 全てをまとめて取り扱う機能を使うことが考えられます。
class Snapshot | Recoil
ここでは前述したチューリングマシンフックを利用して多機能な戻る/進む機能の実装例を紹介します。
実際の実装のデモが次です。”テープに関する操作を実行”で行われた操作を”まとめて戻る/進む”で戻る/進むします。
ソースコードの主だった部分は次です。
ソースコード useTuringTape.ts
import { useState } from "react"; export type UseTuringTapeRet<T> = { current: () => T; next: () => null | T; prev: () => null | T; extendTape: (newData: T) => void; addNextCellWithOverrideCursorIncrement: (cell: T) => void; cutForwardThanCurrent: () => void; reset: (initItem: T) => void; tape: T[]; cursor: number; }; /** * チューリングマシンテープ的な状態管理をするフックです。 * 時系列準等の順番のある状態管理で特に便利です。 * 例えば Undo, Redo 機能のあるシステムです。 * do された時の状態をテープに保持することで Undo, Redo 時にテープのカーソルを動かすだけで直前、直後の状態に遷移できます。 * @param {T[]} initTape テープの初期状態 */ export const useTuringTape = <T>(initTape?: T[]): UseTuringTapeRet<T> => { /** 現在表示している状態を指すカーソル */ const [cursor, setCursor] = useState<number>( initTape ? initTape.length - 1 : -1 ); /** 保持するテープ */ const [tape, setTape] = useState<T[]>(initTape || []); /** テープとカーソルをまっさらにします。axios 等の後から得た非同期データで後から実質的初期化を行うときに便利です */ const reset = (initItem: T): void => { setTape([initItem]); setCursor(0); }; /** 現在状態を返します */ const current = (): T => tape[cursor]; /** 新しい要素を増やしてテープの先端を伸ばします */ const extendTape = (newData: T): void => setTape([...tape, newData]); /** * 現在のカーソルよりも先にテープが余っているならカーソルを先に移動して、移動先要素を返します。 * Undo、Redo用状態管理の例では Redo に対応します */ const next = (): null | T => { const nextCursor = cursor + 1; if (tape[nextCursor] === undefined) { // これ以上先がないならば return null; // no action } setCursor((p) => p + 1); return tape[nextCursor]; }; /** * 現在のカーソルがもっとも手前でないならばカーソルを手前に移動して、移動先要素を返します * Undo、Redo用状態管理の例では Undo に対応します */ const prev = (): null | T => { const prevCursor = cursor - 1; if (tape[prevCursor] === undefined) { // これ以上手前がないならば return null; // no action } setCursor((p) => p - 1); return tape[prevCursor]; }; /** * 現在のカーソルの次の要素を上書きして、次の次から先の既存要素を消して、カーソルを次に動かします * Undo, Redo 例では、新規に do された時に実行して新たな状態をテープに追加します。 * Undo された後にこれを実行することで過去の Redo と混じって惨事になることを防げます。 */ const addNextCellWithOverrideCursorIncrement = (cell: T): void => { setTape([...tape.slice(0, cursor + 1), cell]); setCursor((preCursor) => preCursor + 1); }; /** * 現在のカーソルより先のテープを削除します。 * ここから先に Redo する場合を無くす時に使えます。 */ const cutForwardThanCurrent = () => { setTape([...tape.slice(0, cursor + 1)]); }; return { current, extendTape, next, prev, addNextCellWithOverrideCursorIncrement, cutForwardThanCurrent, reset, tape, cursor }; };
ソースコード useTuringTapesController.ts
import { useEffect } from "react"; import { useTuringTape, UseTuringTapeRet } from "./useTuringTape"; type UseTuringTapesControllerRet = { reset: () => void; undo: () => void; redo: () => void; tape: UseTuringTapeRet<number>; }; // tape は空を想定していないので一番目のマスに入れる何も動かない値が必要 const INIT_VAL = -1; /** * 複数のテープを使った Undo / Redo * @param tapes Undo / Redo 対象のテープ */ export const useTuringTapesController = ( tapes: Array<{ tape: UseTuringTapeRet<any>; tapeInitItem: any; }> ): UseTuringTapesControllerRet => { // このテープの指すカーソルが次に undo すべきテープのインデックスを指します const cursorTape = useTuringTape<number>([INIT_VAL]); // 全テープをリセットします const reset = () => { tapes.forEach((t) => t.tape.reset( typeof t.tapeInitItem === "function" ? t.tapeInitItem() : t.tapeInitItem ) ); cursorTape.reset(INIT_VAL); }; useEffect(() => { // 各テープの新たな要素を追加してカーソルを次に進める処理を改造します tapes.forEach((t, i) => { // 元々の処理を退避させます const old = t.tape.addNextCellWithOverrideCursorIncrement; t.tape.addNextCellWithOverrideCursorIncrement = (v) => { // 元々の処理を呼びます。単に t.tape.~ とすると無限ループです old(v); // 管理用のテープを伸ばします cursorTape.addNextCellWithOverrideCursorIncrement(i); // 操作が起きたテープ以外の現カーソルより先のテープを消します。 // この消す部分はまとめて進む操作では決して参照されない部分です。 // この処理はデバッグ時の混乱を抑えるための処理です(使われていない状態が無駄にあるとわかりにくい)。 tapes.forEach((_t, j) => i !== j && _t.tape.cutForwardThanCurrent()); }; }); }, [tapes, cursorTape]); // 戻る const undo = () => { // 現在管理用テープの指すインデックスのテープがあるならば const i = cursorTape.current(); if (i !== INIT_VAL) { // 管理用テープを戻し cursorTape.prev(); // そのテープを undo します tapes[i].tape.prev(); } }; // 進む const redo = () => { // 管理用テープを一つ進めた部分が次に redo すべきテープのインデックスです const i = cursorTape.next(); if (i != null) { // もしインデックスが返って来たのであれば、そのインデックスのテープを redo します tapes[i].tape.next(); } }; return { reset, undo, redo, tape: cursorTape }; };
使用例
const Example: React.FC = () => { const makeNewData = () => Math.random(); const tapeA = useTuringTape<number>([makeNewData()]); const tapeB = useTuringTape<number>([makeNewData()]); const tapeCursor = useTuringTapesController([ { tape: tapeA, tapeInitItem: makeNewData }, { tape: tapeB, tapeInitItem: makeNewData } ]); return ( <> <button onClick={() => tapeA.addNextCellWithOverrideCursorIncrement(makeNewData())} > テープAに関する操作を実行 </button> <button onClick={() => tapeB.addNextCellWithOverrideCursorIncrement(makeNewData())} > テープBに関する操作を実行 </button> <div className="controller"> <button onClick={() => tapeCursor.reset()}>リセット</button> <button onClick={() => tapeCursor.undo()}>戻る</button> <button onClick={() => tapeCursor.redo()}>進む</button> </div> </> ); }
操作結果を記録するチューリングマシンを複数用意し、その各チューリングマシンを管理するチューリングマシンを用意して、管理マシンに次にどれを戻すか、どれを進めるか、戻し方は、進め方は、と操作方法を色々まかせます。管理フックを使う外からは大雑把に戻してくれ、進めてくれ、初期化してくれ、と命令するのみです。
管理フックに操作すべきマシンを記録していく処理は次のコードで行っています。
// useTuringTapesController.tsx useEffect(() => { // 各テープの新たな要素を追加してカーソルを次に進める処理を改造します tapes.forEach((t, i) => { // 元々の処理を退避させます const old = t.tape.addNextCellWithOverrideCursorIncrement; t.tape.addNextCellWithOverrideCursorIncrement = (v) => { // 元々の処理を呼びます。単に t.tape.~ とすると無限ループです old(v); // 管理用のテープを伸ばします cursorTape.addNextCellWithOverrideCursorIncrement(i); // 操作が起きたテープ以外の現カーソルより先のテープを消します。 // この消す部分はまとめて進む操作では決して参照されない部分です。 // この処理はデバッグ時の混乱を抑えるための処理です(使われていない状態が無駄にあるとわかりにくい)。 tapes.forEach((_t, j) => i !== j && _t.tape.cutForwardThanCurrent()); }; }); }, [tapes, cursorTape]);
オブジェクトのメソッドに後付けでメソッド実行時に管理フックに影響を与える機能を追加しています。この後付けの書き方はなるべく避けるべき書き方ですが、便利な書き方でもあります。
オブジェクトのメソッドを後付けで雑に変えられるのは JavaScript ならではのコードです。この書き方は後付けでメソッド実行時の副作用を好きなだけ付けられる点で便利ですが、バグの原因を探しにくくなるという大きな欠点があります。というのも付けた副作用が原因で元々の処理や紐づいている諸々が壊れると、どこが原因でバグが発生したのか大変わかりにくくなります(本来存在しない処理が走っているように見えます)。追加した部分ですぐ壊れてくれれば原因もすぐわかるのですが、追加処理完了後に遅延して undefined などのエラーが起こると単純なコードジャンプで処理の宣言箇所に行っても原因がわからず、/処理名\s*=/などで検索をかけてようやく原因候補を発見できます。