Restate Your UI: Using State Machines to Simplify User Interface Development

OK, I'll admit it: Every time someone asks me to work on a user interface I cringe. And I'm not talking about a little elevation of the shoulders, I mean the kind of full eyes closed, head around the bellybutton motion that makes the bones in my spine pop. The programs behind user interfaces are usually hard to test, riddled with bugs and -- ironically -- boring. Most user interfaces I've encountered have evolved one haphazard feature after another. The result is code that's fragile, hard to understand, and even harder to maintain.

It doesn't have to be this way. A little up front, top-down design, combined with one of the fundamental models of computation, state machines, is all you need to create maintainable -- and even fun to work on -- user interfaces. I know, you've heard this before: From Swing to XUL to (lately) ReactJS, we are always on the verge of a UI framework that is going to make it all go away. This is not that. What I'm talking about is a technique that has helped make user interface programming fun and effective, in a way that transcends the framework du jour.

Yet Another UI

Let's start by focusing on the basic elements of user interfaces:

  • Event handling functions
  • Rendering functions
  • Application State ("app state")

Here, for example, is the new account registration form that we have all built at one time or other:

Since this is Clojure, our rendering function returns Hiccup data.

(defn render-form
  [app-state]
  [:div
   [:p "Email:"]
   [:input {:value (:email app-state) :on-change ...}]
   [:p "Password:"]
   [:input {:value (:password app-state) :on-change ...}]
   [:button {:on-click handle-submit
             :disabled (:disable-submit app-state)}]])

We'll also need a function to handle the submit button's click event, which disables the button while a request to the Register Service is in flight.

(defn do-register-service-call
  [data callback-fn]
  ...)

We are going to have some event handlers that take an app state and return a new app-state. Keep in mind that the event handlers can have side effects:

(defn handle-submit
  [app-state]
  (do-register-service-call (select-keys app-state [:email :password])
                            handle-submit-success)
  (assoc app-state :disable-submit true))

We also need a second event handling function to deal with the callback from our register service call above, to turn the button back on.

(defn handle-submit-success
  [app-state response]
  (dissoc app-state :disable-submit))

At this point, our app state looks like this:

{:email <string>
 :password <string>
 :disable-submit <boolean>}

More UI More Problems

So far, so good. Our next step is to add some error handling. The rule is that if either field is empty when the submit button is clicked, we show the user an error message. We clear that message when the field changes. We also disable the submit button when an error is visible:

(defn render-form
  [app-state]
  [:div
   (when (:error app-state) [:div (:error app-state)])
   [:p "Email:"]
   [:input {:value (:email app-state) :on-change ...}]
   [:p "Password:"]
   [:input {:value (:password app-state) :on-change ...}]
   [:button {:on-click handle-submit
             :disabled (:disable-submit app-state)}]])

And we need a div to render the error message:

{:email <string>
 :password <string>
 :disable-submit <boolean>
 :error <string>}

And we add an :error attribute to app state.

(defn error
  [{:keys [email password]}]
  (cond (empty? email) "email must not be empty"
        (empty? password) "password must not be empty")

(defn handle-submit
  [app-state]
  (if-let [error (error app-state)]
    ;; If there's an error, don't do the rest of the stuff in `handle-submit`.
    (assoc app-state :error error
                     :disable-submit true)
    ;; Otherwise, call the register service as before.
    (do (do-register-service-call ...)
        (assoc app-state :disable-submit true)))

(defn handle-password-change
  [app-state password]
  (if (starts-with? (:error app-state) "password")
    (-> app-state
        (assoc :password password)
        (dissoc :disable-submit)
        (dissoc :error)
    (assoc :password password)))

;; Similarly, `handle-email-change` also performs the same sort of
;; conditional logic.

We've gone far enough with this example to start seeing problems. Think about trying to add a progress spinner that will do its thing during submission, and you can see that this will get very ugly very quickly.

The problem is that every event handler function which deals with changing a form input's value has to clear both :disable-submit and :error. Not only is there an obvious coupling of these state variables, but worse, they don't even model the actual behavior we want. The submit button should never be enabled (:disable-submit = true) when there is an error present (:error not nil). Since the app state allows this, we're forced to duplicate code prevents it from happening thereby complicating our event handling functions.

Control flow in an event-driven system is based on the sequence of events generated by a user. One of the jobs performed by the event handling functions is to ensure that the program only accepts valid sequences of events and rejects the rest. For example, we disable the submit button to prevent duplicate calls to the register service. While it's common to just sprinkle these kinds of guards wherever we think they are needed, it's a bad idea for two reasons. First, with the key logic scattered here and there the the flow of control is difficult to understand. Second, we end up with complicated -- and tangled -- event-handling functions.

Step back and the larger problem is clear: We have our individual event handlers making decisions about control flow based on context. The decision, for example, to clear an error message when the input is changed depends on whether the error -- if there is one -- is related to the input. The app state, the event itself, and the conditional logic used to make the decision all form an implicit context. Our application is aware of several distinct contexts which it uses for making a variety of decisions. Without some abstraction to name these contexts explicitly, we're forced to build a lot of code in order to make decisions which should be trivial. Returning to our earlier example of a progress spinner, we'd need something like this.

(defn show-spinner?
  [app-state]
  (and (:disable-submit app-state)
       (not (:error app-state)))

If you have done much of this kind of programming you know that this kind of patch work is likely to come apart on the very next feature request, or certainly by the one after that.

Restate Your UI

There is a better way. The overall goal -- one that is common to all user interfaces and independent of whatever library or framework you are employing -- is that as a user navigates a UI, we allow only the actions that make sense in light of the current application state. In other words, as the user does stuff the application should move from one state to another while the set of available actions should change based on the state.

The key word here is state. In computer science, State Machines are abtract entities that have of a finite number of states. Associated with each state is a set of possible transitions. Each transition allows the machine to move to a new state. Sound familair?

Even better, the idea of a state machine fits well with the Clojure prime directive of focusing on the data -- to describe your problem declaratively -- in order to leverage Clojure's powers of data transformation. Our original UI is made up of mostly code. Let's see if we can't turn some of it into a data based state machine:

State         | Submit Button | Error Label | Success Label
 --------------+---------------+-------------+--------------
 Ready           Enabled         nil           nil
 Submitting      Disabled        nil           nil
 Password-Error  Disabled        not nil       nil
 Email-Error     Disabled        not nil       nil
 Success         Disabled        nil           not nil

The UI's events (some subset of them) become the state machine's transitions. For example, clicking the submit button is a transition that takes the UI from the Ready state to the Submitting state. The following table describes our UI's possible state transitions.

From          | Via              | To
 --------------+------------------+-----------
 Ready           missing-password   Password-Error
 Ready           missing-email      Email-Error
 Ready           submit             Submitting
 Password-Error  change-password    Ready
 Email-Error     change-email       Ready
 Submitting      receive-success    Success

I've built state machines for this purpose using a variety of languages and frameworks. Object Oriented representations are straightforward, but bloated. Here is where Clojure's data literals really shine. A compact representation of our UI's state machine might be as simple as the following EDN.

(def fsm {'Start          {:init             'Ready}
          'Ready          {:missing-password 'Password-Error
                           :missing-email    'Email-Error
                           :submit           'Submitting}
          'Password-Error {:change-password  'Ready}
          'Email-Error    {:change-email     'Ready}
          'Submitting     {:receive-success  'Success}})

Data certainly beats code in this instance, for several reasons, not least of which is our ability to visualize it. Rather than creating a diagram of our state machine by hand, we can easily turn the above Clojure map into a directed graph.

user=> (require 'fsmviz.core)
user=> (fsmviz.core/generate-image {'Ready {:missing-password ...} ...}
                                   "fsmui.png")

Both views -- the Clojure map literal, and the diagram -- help us to understand our design, and how we might improve it. In other words, they are tools to help us reason about our design. In addition to a centralized abstraction which describes our control flow, this design adds flexibility, making it easier for us to modify and extend later. In addition to fsm defined above, we'll need a helper function.

(defn next-state
  "Updates app-state to contain the state reached by transitioning from the
 current state."
  [app-state transition]
  (let [new-state (get-in fsm [(:state app-state) transition])]
    (assoc app-state :state new-state)))

Our app state is simplified to contain only a single state flag.

{:email <string>
 :password <string>
 :state <symbol>}

With our new design, we can lift most of the control flow out of our event handlers. In fact, we can even move the call to new-state into some sort of middleware, interceptor, or other hook, fired on every event. This would allow us to completely distill our event handlers down to their essence.

(defn handle-password-change
  [app-state password]
  (-> app-state
      (assoc :password password)
      (next-state :change-password))) ;; <- This probably moves to a hook.

;; Other event handlers become similarly anemic.

Best of all, our context is now explicit. Adding a spinner is now easy: We only need to look at the current state. The coupling between state attributes disappears. We've only got one attribute related to the state of the UI, :state.

(defn render-form
  [app-state]
  (let [state (:state app-state)]
    [:div
     [:p "Email:"]
     [:input {:value (:email app-state)}]
     [:p "Password:"]
     [:input {:value (:password app-state)}]
     [:button {:on-click handle-submit
               :disabled (not= 'Ready state)
               :image (when (= 'Submitting state) "spinner.png")}]])

Final Thoughts

The state machine based approach to building UI is both simple and extensible. There's less code. We've not only reduced the size of app state we've also removed all of the complex logic that we needed to keep it consistent. All gotten by using a simple abstraction -- the state machine -- that is widely understood by developers. So next time you have a user interface that just won't settle down, think about making your life easier with a simple state machine.

Get In Touch