Skip to content

Undo & Redo

Mike Thompson edited this page Jun 15, 2016 · 69 revisions

This document describes features in the upcoming v0.8.0 release of re-frame (although most of the facilities outlined here do exist in earlier versions too, within the re-frame.undo namespace)

re-frame comes with a built-in undo/redo facility.

On Mutations And Undoing

Event handlers cause change - they mutate app-db. Before an event handler runs, app-db is in one state and, after it has run, app-db will be in a new state.

Undoing a user's action means reversing a mutation to app-db. For example, if this happened (dispatch [:delete-item 42]) we'd need to know how to reverse the mutation it caused. But how?

In an OO system, you'd use the Command Pattern to store the reverse of each mutation. You'd remember that the reverse action for (dispatch [:delete-item 42]) was to put a certain item back into a certain collection, at a certain place. Of course, that's often not enough. What happens if there were error states associated with the presence of that item. They would have to be remembered, and reinstated too, etc. There can be a fragile conga line of dependencies.

Luckily, our re-frame world is more simple than that. First, all the state is in one place (not distributed!). Second, to reverse a mutation, we need only reinstate the version of app-db which existed prior to the mutation occurring; prior to the event handler running.

That's it. Holus-Bolus we step the application state back one event.

When the prior state is reinstated, the GUIs will rerender and, bingo, the app is back to where it was before the (dispatch [:delete-item 42]) was processed.

Because the action is Holus-Bolus we are reinstating all derivative (conga line) state too, like calculated errors etc.

So, in summary, to create an undoable event:

  1. immediately before the event handler runs, we save a copy of what's in app-db
  2. run the event handler, presumably causing mutations to app-db
  3. later, if we need to undo this event, we reinstate the saved-away app-db state.

Won't that Be Expensive?

Won't "saving copies of app-db" take a lot of RAM? If the user has performed 50 undoable actions, I'd need 50 copies of app-db saved away! Yikkes.

This is unlikely to be a problem. The value in app-db is a map, which is an immutable data structure. When an immutable data structure is changed (mutated), it shares as much state as possible with the previous version of itself via "structural sharing". Under the covers, our 50 copies will be sharing a lot of state because they are 50 incremental revisions of the same map.

So, no, storing 50 copies is unlikely to be expensive. (Unless you replace huge swathes of app-db with each action)

Just Tell Me How To Do It

You use re-frame.core/undoable middleware on the event handlers whose actions you wish to make undoable. Like this:

(re-frame.core/register-handler 
  :your-handler-id
  (re-frame.core/undoable "This explains the action")  ;; <- undo middleware
  (fn [db [_]] ...))

That's it.

The undoable middleware saves (checkpoints) the current state of app-db BEFORE the event handler runs. As a result the mutations it performs are easily undone.

Widgets

There's going to be widgets, right? There's got to be a way for the user to undo and redo. How do I write these widgets?

Initially, to make it easier, let's assume our widgets are simple buttons: an undo button, and a redo button.

Disabling The Buttons

Our two buttons should be disabled if there's nothing to redo, or undo. To help, re-frame provides two subscriptions:

;; Boolean. Is there anything to undo?
(subscribe [:undos?])   

;; Boolean. Is there anything to redo?  Ie. has the user undone one or more times? 
(subscribe [:redos?])   
Button click

And when our two buttons get clicked, how do we make it happen? re-frame provides handlers for these two built in events:

(dispatch [:undo])  
(dispatch [:redo]) 
The Result

Using these built in features, here's how an undo button might be coded:

(defn undo-button 
  []
   (let [undos?  (subscribe [:undos?])]      ;; only enable the button when there's undos
     (fn []
       [:input {:type "button" 
        :value "undo"
        :disabled  (not @undos?)
        :on-click #(dispatch [:undo]) }]])))  ;; clicking will undo the latest action   

Navigating Undos

Sometimes buttons are not enough, and a more sophisticated user experience is needed.

Some GUIs present to the user a list of their recent undoable actions, allow them to navigate this list, and then choose "a point in time" within these undos to which they'd like to undo. If they clicked 5 actions back in the list, then that will involve 5 undos.

How would we display a meaningful list? For example you might want to show:

  • added new item at the top
  • changed background color
  • increased font size on title
  • deleted 4th item
  • etc

re-frame has you covered. As you saw above, when you use undoable middleware, you can (optionally) provide a string "explanation" for the action being performed. re-frame remembers and manages these explanations for you, and can provide them via built-in subscriptions:

;; a vector of strings. Explanations ordered oldest to most recent
(subscribe [:undo-explanations])  

;; a vector of strings. Ordered from most recent undo onward
(subscribe [:redo-explanations])

If the user chooses to undo multiple actions in one go, notice that you can supply a integer parameter in these events:

(dispatch [:undo 10])   ;; undo 10 actions 
(dispatch [:redo 10])   ;; redo 10 actions 

Fancy Explanations

Normally, undoable middleware is just given a static string like this:

  (undoable "change font size")

but you can get fancy if you want.

You can supply a function instead, and it will be called to generate the explanation.

(undoable my-string-generating-fn)

Your function will be called with arguments XXXX and it is expected to return a string explanation.

Not Everything Should Be Undone - Part I

In my experience, you won't want every event handler to be undoable.

For example, (dispatch [:undo]) itself should not be undoable. And when a handler causes an HTTP GET, and then another handler processes the on-success result, you'd probably only want the initial handler to be undoable, not the on-success handler. Etc. To a user, the two step dispatch is one atomic operation and we only want to checkpoint app-db before the first.

Anyway, this is easy; just don't put undoable middleware on event handlers which should not checkpoint.

Not Everything Should Be Undone - Part II

app-db can sometimes hold BOTH a cache of state from remote sources (databases?) AND some local client-specific state. In such cases, you don't want to undo the state in the cache. The remote source "owns" that state, and it isn't something that the user can undo.

Instead, you'd like to undo/redo the "local" part of app-db - everything below a certain path - and leave the rest alone.

You only ever call this configuration function once, during startup:

(re-frame.core/set-undo-path!  [:some :path])

Further Configuration

How many undo steps do you want to keep? Defaults to 50.

Again, it is expected that you'd only ever call this configuration function once, during startup:

(re-frame.core/set-max-undos!  100)

App Triggered Undo

In our Unhandled Exception Handler, we trigger an undo:

(dispatch [:undo])

We want the application to return to the last-known-good-state. Potentially from there, the user can continue on.

But we don't want a redo hanging around so we also do this:

(dispatch [:purge-redos])

Not that we ever ship programs that suffer exceptions, you understand.