So, here’s an interesting observation: the definition of a remote is simply something that can process queries and mutations…reads and writes. It’s a generalized I/O subsystem! What’s more: the subsystem can be used to isolate your UI from all sorts of things that are normally stateful and coded in the UI at one’s peril.
Here are some examples of abstract remotes that could be useful:
Anything that you’d like to talk to through the more controlled mechanism of queued I/O communication is a potential candidate.
Don’t mistake this for the isolation you already get from mutations. The UI is already nicely separated from the implementation via the mechanism of mutations. Using remotes adds in sequential queues (which can help with asynchrony), and directly attaches you to the auto-normalization and merge capabilities of the server interaction mechanisms. However, it does require more setup code and a bit of extra code (since you have to write a mutation to trigger the interaction, but write the interaction separately), so it may or may not fit your needs.
Just to show the basic pattern for setting up a remote here is a sample of how to integrate with browser local storage through the remoting system.
Note that this particular example would be much shorter and simpler if the remote was not involved, and you may share the opinion that find this technique adds more complexity than it is worth for this particular use-case; however, it is instructional with respect to how server interactions work.
First, we define some simple helpers that can save and load EDN from localstorage by encoding/decoding the EDN to/from string form:
(defn save [edn]
(let [storage js/window.localStorage
stringified-edn (util/transit-clj->str edn)]
(.setItem storage "edn" stringified-edn)))
(defn load []
(let [storage js/window.localStorage
stringified-edn (.getItem storage "edn")]
(or (util/transit-str->clj stringified-edn) {})))
Remotes receive queries and mutations as a standard expression vector (e.g. [(do-thing)]
). As such, we need a parser
that can invoke our remote handlers for each element in the expression. The idea is that on start-up we’ll pull the
value from local storage and keep it in an atom (so we don’t have to re-read it every time). At each interaction,
the sequence is very simple:
A remote can be very small indeed:
(defrecord LocalStorageRemote [parser current-state]
net/FulcroNetwork
(send [this edn done-callback error-callback]
; the parser could have updated state, so we save it after parsing
(let [env {:state current-state :parser parser}
result (parser env edn)]
(save @current-state)
(done-callback result edn)))
(start [this]
(reset! (:current-state this) (load))
this))
In order to understand how this works you should see the rest of the setup.
Basically, we need to create a mutltimethod for read and mutate, then a parser is created that is hooked to them:
(defmulti ls-read prim/dispatch)
(defmulti ls-mutate prim/dispatch)
(defn local-storage-remote
"Creates a remote that uses browser local storage as the back-end. The multimethods for read/write are triggered
to process a request, and the storage value is available in an atom in the parsing env. Changes to the value
in that atom will be auto-propagated back to the storage."
[]
(map->LocalStorageRemote {:current-state (atom {})
:parser (prim/parser {:read ls-read :mutate ls-mutate})}))
”
From there, we just use defmethod
to declare the handlers for queries and mutations against local storage (remember
that state
in these is the state atom in the local storage remote):
(defmethod ls-read :some-key [{:keys [state]} k params]
{:value (-> @state :some-key)})
(defmethod ls-mutate `set-some-key [{:keys [state]} k {:keys [value]}]
{:action (fn [] (swap! state assoc :some-key value))})
(defmethod ls-mutate `bump-it [{:keys [state]} k {:keys [value]}]
{:action (fn [] (swap! state update :some-key inc))})
These look surprisingly familiar, because they are exactly the kinds of things you’d write when writing a server using
multimethods (instead of the helper macros like defquery-root
). We’ve defined one query and two mutations with trivial
implementations.
The local-storage
remote needs to be installed. If you also want server remotes, you’ll have to define a map
that includes a normal remote and also our new local storage “remote”. The options to a new fulcro client would be:
(new-fulcro-client :started-callback (fn [app] (initial-load app))
:networking {:remote (net/make-fulcro-network "/api"
:global-error-callback (constantly nil))
:local-storage (local-storage-remote)}}})
Finally, you’ll see that there is nothing new here. Defining the mutations, you simply forward them on to the new remote using the techniques we’ve already discussed…just name the remote that should be used:
(defmutation set-some-key [params]
; just note the value has changed, but don't optimistically update
(action [{:keys [state]}] (swap! state assoc :ui/stale? true))
; forward the request on to the "remote"
(local-storage [env] true))
(defmutation bump-it [params]
; just note the value has changed, but don't optimistically update
(action [{:keys [state]}] (swap! state assoc :ui/stale? true))
; forward the request on to the "remote"
(local-storage [env] true))
(defmutation clear-stale [params]
(action [{:keys [state]}] (swap! state assoc :ui/stale? false)))
Notice that we’re specifically not doing an optimistic update of the value (and are instead showing a stale marker), so we can demonstrate more clearly that stuff is going on behind the scenes.
When creating the client, we’ll load the local storage value via our well-know mechanism load
. Note that we’re not
using a component, but doing so would get us auto-normalization. For this demo we’re just using a scalar value.
(defn initial-load [app]
(df/load app :some-key nil {;:target [:my-value]
:remote :local-storage
:marker false}))
You’ll notice that there is nothing surprising here. It’s just queries and mutations!
(defsc Root [this {:keys [some-key ui/react-key ui/stale?]}]
{:query [:ui/react-key :some-key :ui/stale?]
:initial-state {:some-key :unset :ui/stale? false}}
(dom/div #js {:key react-key}
(dom/p nil (str "Current value of remote value: " some-key
(when stale? " (stale. Use load to update.)")))
(dom/button #js {:onClick #(df/load this :some-key nil {:remote :local-storage
:post-mutation `clear-stale
:marker false})} "Load the stored value")
(dom/button #js {:onClick #(prim/transact! this `[(set-some-key {:value 1})])} "Set value to 1")
(dom/button #js {:onClick #(prim/transact! this `[(bump-it {})])} "Increment the value")))
NOTE: This demo does not do optimistic updates. When you click on the buttons that change state, they only talk
to the remote. Clicking on the load
button will update the UI by running a query against our local storage simulated
“remote”. Reloading the browser page will also run the query on startup, which should restore the value that is in
your current local storage.
(ns local-storage-as-a-remote
(:require [fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.dom :as dom]
[fulcro.client.cards :refer [defcard-fulcro]]
[fulcro.client.mutations :as m :refer [defmutation]]
[fulcro.client.network :as net]
[fulcro.client.util :as util]
[fulcro.client :as fc]
[fulcro.client.data-fetch :as df]))
(defn save [edn]
(let [storage js/window.localStorage
stringified-edn (util/transit-clj->str edn)]
(.setItem storage "edn" stringified-edn)))
(defn load []
(let [storage js/window.localStorage
stringified-edn (.getItem storage "edn")]
(or (util/transit-str->clj stringified-edn) {})))
(defrecord LocalStorageRemote [parser current-state]
net/FulcroNetwork
(send [this edn done-callback error-callback]
; the parser could have updated state, so we save it after parsing
(let [env {:state current-state :parser parser}
result (parser env edn)]
(save @current-state)
(done-callback result edn)))
(start [this]
(reset! (:current-state this) (load))
this))
(defmulti ls-read prim/dispatch)
(defmulti ls-mutate prim/dispatch)
(defmethod ls-read :some-key [{:keys [state]} k params]
{:value (-> @state :some-key)})
(defmethod ls-mutate `set-some-key [{:keys [state]} k {:keys [value]}]
{:action (fn [] (swap! state assoc :some-key value))})
(defmethod ls-mutate `bump-it [{:keys [state]} k {:keys [value]}]
{:action (fn [] (swap! state update :some-key inc))})
(defmutation set-some-key [params]
; just note the value has changed, but don't optimistically update
(action [{:keys [state]}] (swap! state assoc :ui/stale? true))
; forward the request on to the "remote"
(local-storage [env] true))
(defmutation bump-it [params]
; just note the value has changed, but don't optimistically update
(action [{:keys [state]}] (swap! state assoc :ui/stale? true))
; forward the request on to the "remote"
(local-storage [env] true))
(defmutation clear-stale [params]
(action [{:keys [state]}] (swap! state assoc :ui/stale? false)))
(defn local-storage-remote
"Creates a remote that uses browser local storage as the back-end. The multimethods for read/write are triggered
to process a request, and the storage value is available in an atom in the parsing env. Changes to the value
in that atom will be auto-propagated back to the storage."
[]
(map->LocalStorageRemote {:current-state (atom {})
:parser (prim/parser {:read ls-read :mutate ls-mutate})}))
(defsc Root [this {:keys [some-key ui/react-key ui/stale?]}]
{:query [:ui/react-key :some-key :ui/stale?]
:initial-state {:some-key :unset :ui/stale? false}}
(dom/div #js {:key react-key}
(dom/p nil (str "Current value of remote value: " some-key
(when stale? " (stale. Use load to update.)")))
(dom/button #js {:onClick #(df/load this :some-key nil {:remote :local-storage
:post-mutation `clear-stale
:marker false})} "Load the stored value")
(dom/button #js {:onClick #(prim/transact! this `[(set-some-key {:value 1})])} "Set value to 1")
(dom/button #js {:onClick #(prim/transact! this `[(bump-it {})])} "Increment the value")))
(defn initial-load [app]
(df/load app :some-key nil {;:target [:my-value]
:remote :local-storage
:marker false}))
(defonce app (atom
(fc/new-fulcro-client :started-callback (fn [app] (initial-load app))
:networking {:remote (net/make-fulcro-network "/api"
:global-error-callback (constantly nil))
:local-storage (local-storage-remote)}}})))