Optimistic Update in Re-frame

Build your SPA in ClojureScript!

Master Reagent and Re-frame with my ClojureScript Frontend Signature Course.

  • 3 frontend modules
  • 72 detailed lessons
  • 19 hours of video
ClojureScript Frontend: An Eric Normand Signature Course

We've got a little bit of a problem on the web, especially with frontend apps. Our users are enjoying the fast, interactive experience of the app, which is running in the browser. Then some change needs to be propagated on the server. The server has to be the source of truth, since the browser tab is ephemeral. It could be reloaded at any time! Let's say we click the "Like" button on a picture on social media. Obviously, we need to tell the server. The server knows all the likes and until it gets to the server, no other user can see the like. However, we want the user to know that the like happened or is at least in the process of happening.

In the ideal case, we send a message to the server, get a successful response, and update the UI; and all of that happens so fast the user never wonders whether it happens.

When the user clicks the like button, we dispatch the :like event, which sends a POST to the server. Then on success, we store some state for that post id in a hash map. If there's an error, we put an error message in the database.

(rf/reg-event-fx :like
  (fn [cofx [_ post-id]]
    {:http-xhrio {:url (str "http://server.com/" post-id "/like")
                  :method :post
                  :on-success [:confirm-like post-id]
                  :on-failure [:show-error "Could not like."]}}))

(rf/reg-event-db :confirm-like
  (fn [db [_ post-id]]
    (assoc-in db [:likes post-id] true)))

(rf/reg-event-db :show-error
  (fn [db [_ msg]]
    (assoc db :last-error msg)))

However, this ideal is rare. The message to the server and back is too slow. The user wants instant feedback. What can we do?

Pessimistic update

Well, one option is to save something to the database saying that we're trying to confirm the like. That way, we can show a loading spinner. In addition to sending the POST, we can store that we're trying. Let's create a new structure to house all of the state we'll need to keep track of. Let's use a map. It will have a status and a value.

{:status :stable
 :value  true}

 {:status    :loading
 :value      true
 :next-value false}

There are two statuses: :stable and :loading. Stable means we don't anticipate a response from the server. The value is what it is and is not changing. Loading means we are awaiting a response from the server. This is the case where we'd show a loading spinner.

We can make three operations on this new map. The first one, begin-load, we call when we start the Ajax request. It stores the value we're hoping it will change to under :next-value.

There's also an operation for succeeding, succeed-load, which sets the status to stable and updates the value.

And for failure responses, fail-load sets it back to stable, but leaves the value alone. I've also defined some Events that call these on a certain path in the Database.

(defn begin-load [state next-value]
  (cond
    (nil? state) ;; consider nil a stable value
    {:status :loading
     :value nil
     :next-value next-value}

    (= :stable (:status state))
    {:status :loading
     :value (:value state)
     :next-value next-value}

    (= :loading (:status state))
    (assoc state :next-value next-value)))

(defn succeed-load [state]
  (cond
    (nil? state)
    state

    (= :stable (:status state))
    state

    (= :loading (:status state))
    {:status :stable
     :value (:next-value state)}))

(defn fail-load [state]
  (cond
    (nil? state)
    state

    (= :stable (:status state))
    state

    (= :loading (:status state))
    {:status :stable
     :value (:value state)}))

(rf/reg-event-db :succeed-load
  (fn [db [_ path]]
    (update-in db path succeed-load)))

(rf/reg-event-db :fail-load
  (fn [db [_ path msg]]
    (-> db
      (update-in path fail-load)
      (assoc :last-error msg))))

Alright! Now how do we use this little bit of machinery? We use begin-load to start it off, telling it we're trying to make it true. We can use the generic :succeed-load and :fail-load events, giving them the path that they'll need when they are dispatched.

(rf/reg-event-fx :like
  (fn [cofx [_ post-id]]
    ;; let's start the ajax request
    {:http-xhrio {:url (str "http://server.com/" post-id "/like")
                  :method :post
                  ;; we can call our new Events
                  :on-success [:succeed-load [:likes post-id]]
                  :on-failure [:fail-load    [:likes post-id] "Failed to like post."]}
     ;; and let's begin the load with next value true
     :db (update-in (:db cofx) [:likes post-id] begin-load true)}))

In our Component, if the status is stable, we show the value. Otherwise, we show a spinner. That's one option. We show exactly the truth: we're loading. The actual state of things is in limbo.

Optimistic update

But there's another way, which is to tell a 90% truth. 90% of the time, the Ajax request will succeed with no problem. So 90% of the time, you can just pretend like it's going to work, showing what it would look like if it were a success immediately. You then you have to deal with those 10% of cases where it didn't work---you actually lied to the user. But the lucky thing is that your users will get feedback immediately. That feedback is correct the vast majority of the time. And sometimes, that's good enough.

In this app, liking something is not an essential feature, so we can update it optimistically. With just a few small changes, we can do an optimistic load. The first thing is to immediately set the value to the next value, and store the old value.

{:status :stable
 :value  true}

 {:status    :loading
 :value      false
 :old-value true}

We change our begin-load function so it doesn't save the current value and the next value anymore. Now, it will save the current value, which it optimistically sets to next-value, and the old value, which we'll need in case of failure. Success means we throw away the old value. And failure means we set the current value to the old value.

(defn begin-load [state next-value]
  (cond

   (nil? state)
    {:status :loading
     :value next-value
     :old-value nil}

    (= :stable (:status state))
    {:status :loading
     :value next-value
     :old-value (:value state)}

    (= :loading (:status state))
    (assoc state :value next-value)))

(defn succeed-load [state]
  (cond
    (nil? state)
    state

    (= :stable (:status state))
    state

    (= :loading (:status state))
    {:status :stable
     :value (:value state)}))

(defn fail-load [state]
  (cond
    (nil? state)
    state

    (= :stable (:status state))
    state

    (= :loading (:status state))
    {:status :stable
     :value (:old-value state)}))

I didn't repeat the Events because they're the same. We just had to change these three operations. Well, we also have to change the view. It should never show the loading spinner now. It should just show the current value and there should still be a message when there's a failure.

More posts in this Re-frame Series

Build your SPA in ClojureScript!

Master Reagent and Re-frame with my ClojureScript Frontend Signature Course.

  • 3 frontend modules
  • 72 detailed lessons
  • 19 hours of video
ClojureScript Frontend: An Eric Normand Signature Course