React Suspense and ClojureScript

React recently has been quietly releasing a lot of updates that will, in my opinion, be transformative in how we build web applications. And they do it by releasing small composable elements that can be used a-la carte (or not at all) to do very very cool stuff.

One of those elements is the “Suspense” element. This is something like an exception handler but for data fetching. When a child element of a Suspense elements “suspends” during render, for example if it needs to load data or code, then React can detect that and show a fallback element (like a loading spinner or such), then try again when the required resource is available.

In the newly-released experimental “Concurrent Mode”, the Suspense element can do more things - it can be synchronised with other suspense elements so that they show in a specific order, it can decide to wait a little bit to avoid “flashing spinners” and more. This is all powering the new Facebook page and dogfooded there, so there’s a lot of real-life use cases they’re trying to solve.

The way this works behind the scenes, is that a component can throw a Promise during render. React will then “suspend” it, and when the promise resolves, it will re-render it.

It turns out that IDeref is a nice little abstraction that can be used for this - the consuming component just derefs a property passed in, without caring what happens behind the scenes.

Here’s some toy example code using hx:

;; data is a deref-able, but the component doesn't know
;; what's going on behind the scenes
(defnc SubMain [{:keys [data]}]
  [:div "Sub main " @data])

(defnc Loading []
  [:div "Loading..."])

(defnc Main []
  ;; you obviously shouldn't load-data when rendering, but this is a toy example
  (let [data (load-data #(fake-fetch "PAYLOAD"))]
    [:div "Hello"
    ;; need to wrap Loading with hx/f to pass it in as a prop
     [React/Suspense {:fallback (hx/f [Loading])}
      [SubMain {:data data}]]]))

And here’s how the magic happens:

(defn timeout-promise [millis]
  (js/Promise.
   (fn [resolve reject]
     (js/setTimeout resolve millis))))

(defn fake-fetch [payload]
  (-> (timeout-promise 5000)
      (.then (fn [] payload))))

(defn load-data [fetch-fn]
  (let [p     (fetch-fn)
        state (atom {:loaded  false
                     :payload nil
                     :error   nil
                     :promise p})]
    (.then p
           (fn [payload]
             (swap! state assoc
                    :loaded true
                    :payload payload))
           (fn [error]
             (swap! state assoc :error error)))
    (reify IDeref
      (-deref [_]
        (cond
          ;; if promise has been resolved, return the payload, render as usual
          (:loaded @state) (:payload @state)
          ;; if promise has been rejected, throw the error
          (:error @state) (throw (:error @state))
          ;; else, promise has not been resolved, so suspend by throwing the promise
          :else (throw (:promise @state)))))))

You could test this right now with the latest stable React release and it will work. To get the error handling to work you need a custom ErrorBoundary, which React doesn’t provide out of the box, but that’s a topic for another time.

There’s a lot of potential to this – you could implement an ISeqable that fetches data from the network, an ICounted that can do pagination etc etc.

If you’re interested in learning more about what React is cooking, there’s a blog post and also some enlightening videos from the recent React Conf.

5 Likes