Reseda: Frontend without async -- React with Suspense

Hello all. I’ve recently announced Reseda, a “A Clojure-y state management library for modern React, from the future :rocket:” – and I’d like to briefly introduce the concepts behind it.

The main building block of Reseda is the concept of the “store” which is something that builds on top of Clojure atoms but allows more fine-grained subscriptions.

(:require [reseda.state])

;; create the atom
(defonce backing-store (atom {}))

;; create a new store
(defonce store (reseda.state/new-store backing-store))

With a store at hand, you can subscribe to get notifications when things change:

;; whenever the value under :fred changes, print it
(subscribe store :fred println)

;; you can pass in any function as the selector, not just keywords:
(subscribe store identity tap>)
(subscribe store (fn [m] (select-keys m [:fred :wilma])) tap>)

;; as a convenience, you can also pass a vector of keywords as the selector, like get-in
(subscribe store [:fred :name] #(log %))

And when you want to use it from React, you can use reseda.react/useStore that will make sure your component re-renders whenever the underlying value changes:

;; The first render will give you whatever is in the store, and
;; from then on, your component will re-render whenever the value changes
(defnc Name []
  (let [name (reseda.react/useStore store [:user :name])]
   [:div "The user's name is: " name]))

Note that this is a plain React hook, and therefore it can be used wherever React hooks are valid, with libraries like Helix or hx that use plain React components, or with Reagent support for Hooks.

Note that this is Concurrent Mode Ready™, assuming there’s no huge breaking changes when Concurrent Mode is actually out.

Multiple Stores

You can have as many stores as you’d like – one global if you want to have something like re-frame, one per namespace, or even use stores for local state – the API is exactly the same, you just need to make sure that the backing atom and the store are stable (e.g. for local state you need to stick them in a React Ref or equivalent).

Updates

At the simplest level, you can just manipulate the backing atom using plain Clojure functions - swap!, reset! etc. You can even add validators to enforce invariants! Or you can add your own atom watcher for debugging and so on.

However, a lot of times you’ll want to have something more declarative, perhaps dispatching events like re-frame. Well, Reseda stays out of this game – but you can easily use tried and true Clojure multi-methods to get something very very nice:

(defmulti state-change
  (fn [_state event args]
    event))

(defmethod state-change :foo
 [state event args]
;;; return a new state
)

(defonce state (atom {}))
(defn dispatch! [event payload]
    (swap! state state-change event payload))

Then elsewhere in your code, just call (dispatch! :foo [1 2 3]).

There might be other patterns of state update that you can implement yourself, using just plain Clojure.

Without Async?

What’s that about no async? Well, React since a long time supports Suspense, and recently Suspense for data fetching which is currently in beta, but already works in React Stable.

Reseda provides a Suspending type that wraps a promise and works with React.

You create it like so:

(defn fetch-api []
 ;; fetch a remote resource and return a Javascript Promise
 ,,,)

(defonce backing-store (atom {:data (-> (fetch-api)
                                        (reseda.react/suspending-value))}))
(defonce store (reseda.state/new-store backing-store))

And you consume it like so:

(defnc RemoteName [{:keys [data*]}]
 ;; using a trailing * for reader clarity -- 
 ;; this is a Suspending and you need to deref it
 ;; notice the @ that derefs the Suspending
  [:div "The remote data is: " @data*])

(defnc Root []
 ;; see note about Suspense boundaries 
 ;; -- you cannot have them in the same component that suspends
(let [data* (reseda.react/useStore store :data)]
  [React/Suspense {:fallback (hx/f [:div "Loading..."])}
   [RemoteName {:data* data*}]]))

At the point of the deref, React will be notified that the promise is still pending, and will instead render the fallback element. Once the promise resolves, React will re-render and the data appears. This solves quite a lot of race conditions and issues for loading data, effectively it’s like you are reifying the promise as a value, which means that if a new value comes in, React will discard the previous one. This means that for 90% of the use cases, you don’t even need to deal with asynchrony. Just stick a new Suspending in your store and let React re-render.

The final nicety that Reseda provides is a way to keep around previous Suspending values to avoid showing the fallback every time a new fetch is kicked off. React in Concurrent Mode provides a useTransition hook that deals with this, but in React Stable we have to deal with this issue in user-land. The hook is called useSuspending:

(defnc SearchList []
 (let [[results* loading?] (-> (reseda.react/useStore store :results) 
                               (reseda.react/useSuspending))]
   [SearchBox {:show-spinner loading?
               :on-change 
               (fn [text]
                (swap! backing-store :results (fetch-new-results text)))}]
   [React/Suspense
    [SearchResults {:results* results*
                    :loading? loading?}]]))

useSuspending will cache the last resolved (or rejected) value and show that until the next one comes in. It will even give you a boolean value back that shows the state of the loading, so you can render a spinner or any other loading indicator.


I hope you will see this post and take Reseda out for a quick spin, and provide feedback :slight_smile:

8 Likes