編集履歴
2016/12/11 サンプルコードと一部文面を修正しました。サンプルレポジトリを追加しました。
はじめに
今回はReduxでのundo/redoについて掘り下げていきます。undo/redoは人によっては全く縁がないかもしれない機能です。さらに言えば必要となる内容も目的によって大きく異なってきます。思いのほかまとめた記事がなく、実装する際に色々と考えることになったので、今回まとめてみようと思いました。
ざっと考えた使用例
- 永続化層と同期するstateのデータ追加/更新/削除のundo/redo
- 開発者向けのデバッグ機能
- 画面の状態のundo/redo
永続化層と同期するstateの追加/更新/削除のundo/redo
正直あまりないケースだと思います。uploadや削除の取消くらいでしょうか(正確にはundo/redoではないですが)。通信などのコストが発生することが多く、そもそも気軽に何回もundo/redoできるべきかを考える必要がありそうです。
開発者向けのデバッグ機能
actionによりstateがどう更新されたか確認したい際によく使われていると思います。単純にactionが1回dispatchされるにつきstateのsnapshotを1つstackに詰め込めば終了です。色々と実装の手段はあるでしょうが、そんなに難しくはならないと思います。
画面の状態のundo/redo
プロトタイプ作成ツールやExcelライクのツールなどUIで操作できる内容が多いとundo/redoが欲しくなるのではないでしょうか。UIの種類によって難易度が大分異なると思います。当たり前な気もしますが扱うstateが複雑になればなるほど難しい印象が強いです。何が難しいかもう少し掘り下げて考えていきます。
個人的に思う画面の状態のundo/redoが難しくなる理由
- undo/redoの対象になる状態とならない状態がある
- エンドユーザの期待するundo/redoの単位とactionの単位が一致しない
(複数のactionを一括でundo/redoしなければいけないケースが出てくる)
undo/redoの対象になる状態とならない状態がある
当然といえば当然の話です。メニューの開閉などまでundo/redoの対象にするわけにはいきません。reduxのstate内でundo/redoの対象となるものとならないものをしっかりと分けましょう。redux-undo を使えばreducer単位でundo/redoを仕込むことができます。全部を解決してくれるわけではないですが個人的にはおススメです。
付け加えると後からundo/redoのためにstateの構造を書き換えるのは非常に辛いです。undo/redoを検討している方は実装を開始する前に、undo/redo用にstateの構造を考えておくことを強く推奨します。
エンドユーザの期待するundo/redoの単位とactionの単位が一致しない
action単位でundo/redoができれば「開発者向けデバッグ機能」と同様で簡単ですが、複数のactionを一括でundo/redoしたいケースが大なり小なり出てくると思います。例えば入れ子構造や階層構造のUIを表現しようとするケースや、一括で画面のstateの追加/更新/削除などを行うケースです。頑張れば1つのactionで対応することはできそうですが、個人的には無理せず分割した方がよいのではないかと考えています。
これでは流石に内容が寂しいので、実際に私が実装した内容について簡単に解説できればと思います。上で紹介したredux-undoというモジュールを使用してるので、まずこのモジュールについての解説をざっくりと行います。これはreducerを引数にし、reducerを返すhigher-order-reducerです。undo/redoの対象となるreducerを以下のように決定します。
import redux from 'redux'
import reduxUndo from 'redux-undo'
import itemReducer from './item'
const undoableReducer = reduxUndo(items)
const store = createStore(undoableReducer)
特定のaction(指定可)でundo/redoを行うことができます。redux-undoは単純に対象のstateを配列にしているだけです。
const reduxUndoState = {
past: [],
present: state,
future: []
}
undoが実行されるとpastがpresentにpresentがfutureに移動します。例としては以下のような形です。
// 最初
{
past: [1, 2, 3],
present: 4,
future: []
}
// undo一回実行
{
past: [1, 2],
present: 3,
future: [4]
}
// 続いてredo一回実行
{
past: [1, 2, 3],
present: 4,
future: []
}
短いので詳しく知りたい方はredux-undoのソースでも読んでください。
redux-undoの処理はundo用のactionが使用される度に、stateのsnapshotがarrayにpushされていると考えることができると思います。なのでundo/redoしたい単位でsnapshotを取ってしまえばよいだけの話です。最終的にこんな形になりました。
/** store.ts */
import itemReducer from './item'
// undo/redoを実行するactionのtypeを指定
const stackedRootReducer = reduxUndo(items, {
undoType: 'UNDO',
redoType: 'REDO'
})
const unStackedRootReducer = items
// action.type === 'STACK_STATEの時、stateのsnapshotを保存'
const unStackedRootReducer = combineReducers<State>({
items: reduxUndo(items, {
filter: includeAction([]),
redoType: REDO,
undoType: UNDO
}),
application
})
export default behaviourSwitch(stackedRootReducer, unStackedRootReducer)
/** App.tsx */
export default class extends React.Component<Props, State> {
(...略)
// store.dispatchをラップした関数をコンテキストで宣言
getChildContext() {
const { dispatch } = this.props.store
return {
dispatch: (actions) => {
Array.isArray(actions)
? actions.forEach((action) => {
dispatch(action)
})
: dispatch(actions)
},
dispatchUndo: (actions) => {
// undoする場合は最初にstateのsnapshotを取る
dispatch({type: 'STACK_STATE'})
Array.isArray(actions)
? actions.forEach((action) => {
this.props.store.dispatch(action)
})
: this.props.store.dispatch(actions)
}
}
}
(...略)
良く分からん。サンプル見せろ
書きました。以下のリンクからお願いします。一括でundoできるのが一括削除くらいのシンプルなToDoリストです。上で示したソースと比べて少し異なっていますが、必要な個所はそこまで変わっていないはずです。
実際のところ本当にこれで大丈夫?
駄目です。言うまでもないかもしれませんが、残念ながら非同期処理が入ったらstateが更新される順番は保証されなくなります。全く無関係のactionによる変更までundo/redoされてしまいstateの不整合が起こりかねません。
一応の対策
一応対策としてactionをdispatchする前に非同期処理を終了させるということができなくはないと思います。しかし非同期通信の度に逐次stateの更新がしたいという話になるとかなり厳しいです。その場合は対象のstateを別のreducerに分けてしまった方が良いと思います。
本当に必要?
そもそも機能としてundo/redoとして提供しないという手も十分にありだと思います。仮にユーザが操作ミスをしても容易に元の状態に戻せるのなら無理して実装する必要すらありません。永続化層と同期をとる場合、キーボードを叩くだけで簡単にundo/redoできてもいけないケースもありえるでしょう。
以上のように少し掘り下げてみるとundo/redoは思ったより考えることのある機能だと思います。個人的には課題が山積みなので、何かご意見がございましたらコメントなどいただけると非常に助かります。正直Redux使わなければundo/redoの実装はもっと楽だったと思う。