State management with atoms
In this tutorial we will implement state management for top-down rendering with an atom as the global store. See State management with Datascript for an alternative take on this tutorial.
This tutorial isn’t really Replicant-specific: you can use the suggest approach with any rendering library.
As explained in top-down rendering, Replicant is built around the idea that you render your entire app whenever the app state changes. In this tutorial we will use a single atom to hold all the application state, and add a listener that makes sure the app re-renders every time it changes.
It’s useful to differentiate the atom that holds the current state, and the
snapshot/current state. I like to use the name store
for the atom and state
for the value inside.
§ Basic setup
The store
can be created in the main function that starts the app, but it’s
rather useful to be able to access it in a REPL, so I usually defonce
it in a
suitable place:
(defonce store (atom {}))
Next we will need a function that can render the app:
(defn render [state]
[:div
[:h1 "Hello world"]
[:p "Started at " (:app/started-at state)]])
Finally, we’ll add a main
function that sets up the watcher and renders the
app when the store changes:
(ns state-atom.core
(:require [replicant.dom :as r]))
,,,
(defonce el (js/document.getElementById "app"))
(defn ^:dev/after-load main []
(add-watch
store ::render
(fn [_ _ _ state]
(r/render el (render state))))
;; Trigger the initial render
(swap! store assoc :app/started-at (js/Date.)))
We now have a basic setup. You can verify that things work by updating the
:app/started-at
attribute. Evaluate the following expression in the REPL:
(swap! store assoc :app/started-at (js/Date.))
When this is evaluated, the UI should automatically update.
§ Updating the store
Our UI now responds to changes in the store, great. The next step is to put in place a small system for updating the store based on user interaction. To do this we will use the action dispatch pattern detailed in the event handlers guide. Instead of building it from scratch, we will use Nexus:
(ns state-atom.core
(:require [nexus.registry :as nxr]
[replicant.dom :as r]))
,,,
(nxr/register-system->state! deref)
(defn ^:dev/after-load main []
,,,
(r/set-dispatch!
(fn [dispatch-data actions]
(nxr/dispatch store dispatch-data actions)))
;; Trigger the initial render
(swap! store assoc :app/started-at (js/Date.)))
We now have a tiny tailor-made framework. Next we will add an effect that updates the store:
(nxr/register-effect! :store/assoc-in
(fn [_ store path value]
(swap! store assoc-in path value)))
:store/assoc-in
is just what the name implies: an assoc-in
for the
application state in store
. It’s a one-liner, and will serve 90%, if not more,
of your state management needs. Amazing.
NB! This example registers actions and effects globally for convenience. See the
Nexus docs for how to use nexus.core
to
avoid global state entirely.
Let’s see it in use:
(defn render [state]
(let [clicks (:clicks state 0)]
[:div
[:h1 "Hello world"]
[:p "Started at " (:app/started-at state)]
[:button
{:on {:click [[:store/assoc-in [:clicks] (inc clicks)]]}}
"Click me"]
(when (< 0 clicks)
[:p
"Button was clicked "
clicks
(if (= 1 clicks) " time" " times")])]))
This is an essential Replicant UI: it’s a pure function, it returns pure data
(including the event handler). Even though the mechanics of updating the store
is not in this function, it is quite obvious how (:clicks state)
and
[:store/assoc-in [:clicks] (inc clicks)]
are related.
§ Pure domain-specific actions
While low-level utilities like :store/assoc-in
will take you far, it doesn’t
always capture the intention in terms of your business domain well. We can solve
this by adding high-level actions that expand to low-level effects.
Here’s the above UI expressed with a high-level domain action:
(defn render-page [state]
(let [clicks (:clicks state 0)]
[:div
[:h1 "Hello world"]
[:p "Started at " (:app/started-at state)]
[:button
{:on {:click [[:counter/inc [:clicks]]]}} ;; <==
"Click me"]
(when (< 0 clicks)
[:p
"Button was clicked "
clicks
(if (= 1 clicks) " time" " times")])]))
We can implement this action as a pure transformation to the side-effecty
:store/assoc-in
:
(nxr/register-action! :counter/inc
(fn [state path]
[[:store/assoc-in path (inc (get-in state path))]]))
As your app grows, you will add new actions like this, that are pure and easy to test. Only add new effects when your app needs new capabilities.
§ Batched state updates
Some interactions require adding more than one piece of state. Currently, each
:store/assoc-in
becomes a separate swap!
, which causes a render. This means
that dispatching three actions will trigger three consecutive renders – not
ideal. We can fix this by batching the swap!
:
(nxr/register-effect! :store/assoc-in
^:nexus/batch
(fn [_ store path-values]
(swap! store
(fn [state]
(reduce (fn [s [path value]]
(assoc-in s path value)) state path-values)))))
The other actions do not need to change, Nexus will know to do the right thing
based on the ^:nexus/batch
meta on the effect function.
§ Beyond assoc-in
As previously mentioned, assoc-in
will take care of 90 % of your state
management needs. But what about the remaining 10 %? Let’s add the ability to
use dissoc
and conj
to see how to go about it.
In order to batch swap!
s, we need to collect side-effects in a way that can be
reduced to a single state. One way to achieve this is to change
:store/assoc-in
to :store/save
and then provide the operation as a keyword
– a sub-effect of sorts.
Let’s first add two helper functions to make dissoc
and conj
work on nested
data structures, just like assoc-in
:
(defn dissoc-in [m path]
(if (= 1 (count path))
(dissoc m (first path))
(update-in m (butlast path) dissoc (last path))))
(defn conj-in [m path v]
(update-in m path conj v))
Now we can write our state updating function:
(defn update-state [state [op & args]]
(case op
:assoc-in (apply assoc-in state args)
:dissoc-in (apply dissoc-in state args)
:conj-in (apply conj-in state args)))
And finally we can add our new effect, which reduces over the operations with
update-state
:
(nxr/register-effect! :store/save
^:nexus/batch?
(fn [_ store ops]
(swap! store
(fn [state]
(reduce update-state state ops)))))
To use this effect we have two options: Replace existing occurrences of
[:store/assoc-in path value]
with [:store/save :assoc-in path value]
– or
implement :store/assoc-in
as an action. This way we won’t have to change any
of the UI code:
(nxr/register-action! :store/assoc-in
(fn [_ path value]
[[:store/save :assoc-in path value]]))
Now you really have a good foundation for atom/map based state management.
NB! The provided implementation of update-state
will not work on seqs and
lists. When storing app state in an atom like this I recommend you store
collections as maps keyed by id, as you can add, edit and remove items from
those with assoc-in
and dissoc
.
The code from this tutorial is available on Github: feel free to use it as a starting template for building an app with atom based state management. Also consider checking out the state management with Datascript tutorial.
If you’d rather build the action dispatch yourself and not use Nexus, there is also a hand-written version available.
§ Bonus: Routing
In the routing tutorial we built a small routing system for a top-down rendered app. In this bonus section, we’ll integrate the routing solution with the atom based state management we just created.
Routing and state management are orthogonal concerns, but both need to trigger rendering. The system as a whole will be easier to reason about if rendering only happens one way. We’ll keep the render hook on the state atom, and have the routing system render indirectly through the atom by storing the current location in it.
We start by copying over the router namespace:
(ns state-atom.router
(:require [domkm.silk :as silk]
[lambdaisland.uri :as uri]))
(def routes
(silk/routes
[[:pages/episode [["episodes" :episode/id]]]
[:pages/frontpage []]]))
(defn url->location [routes url]
(let [uri (cond-> url (string? url) uri/uri)]
(when-let [arrived (silk/arrive routes (:path uri))]
(let [query-params (uri/query-map uri)
hash-params (some-> uri :fragment uri/query-string->map)]
(cond-> {:location/page-id (:domkm.silk/name arrived)
:location/params (dissoc arrived
:domkm.silk/name
:domkm.silk/pattern
:domkm.silk/routes
:domkm.silk/url)}
(seq query-params) (assoc :location/query-params query-params)
(seq hash-params) (assoc :location/hash-params hash-params))))))
(defn location->url [routes {:location/keys [page-id params query-params hash-params]}]
(cond-> (silk/depart routes page-id params)
(seq query-params)
(str "?" (uri/map->query-string query-params))
(seq hash-params)
(str "#" (uri/map->query-string hash-params))))
(defn essentially-same? [l1 l2]
(and (= (:location/page-id l1) (:location/page-id l2))
(= (not-empty (:location/params l1))
(not-empty (:location/params l2)))
(= (not-empty (:location/query-params l1))
(not-empty (:location/query-params l2)))))
Next, we’ll add the routing alias to the core namespace:
(ns state-atom.core
(:require [nexus.registry :as nxr]
[replicant.alias :as alias]
[replicant.dom :as r]
[state-atom.router :as router]
[state-atom.ui :as ui]))
(defn routing-anchor [attrs children]
(let [routes (-> attrs :replicant/alias-data :routes)]
(into [:a (cond-> attrs
(:ui/location attrs)
(assoc :href (router/location->url routes
(:ui/location attrs))))]
children)))
(alias/register! :ui/a routing-anchor)
Then we’ll copy over the helper functions:
(ns state-atom.core
,,,)
,,,
(defn find-target-href [e]
(some-> e .-target
(.closest "a")
(.getAttribute "href")))
(defn get-current-location []
(->> js/location.href
(router/url->location router/routes)))
To handle the initial routing when the app boots we can find the location in the
main
function and store it when we trigger the initial render:
;; Trigger the initial render
(swap! store assoc
:app/started-at (js/Date.)
:location (get-current-location))
To handle click events, we will extract the core of the old route-click
function as an action. We will need to make some changes to achieve this. First
of all, we need the routes object to be a part of the Nexus system:
(defn main [store el]
,,,
(r/set-dispatch!
(fn [dispatch-data actions]
(nxr/dispatch {:store store
:routes router/routes} dispatch-data actions)))
,,,)
We will only need the routing table in side-effecting effects, so the
system->state
function can still just return the deref-ed store:
(nxr/register-system->state! (comp deref :store))
This way we won’t need to change any of the action implementations, only the
:store/save
effect:
(nxr/register-effect! :store/save
^:nexus/batch
(fn [_ {:keys [store]} ops]
(swap! store
(fn [state]
(reduce update-state state ops)))))
We can now define a new effect that updates the browser’s current URL:
(nxr/register-effect! :effects/update-url
(fn [_ {:keys [routes]} new-location old-location]
(if (router/essentially-same? new-location old-location)
(.replaceState js/history nil "" (router/location->url routes new-location))
(.pushState js/history nil "" (router/location->url routes new-location)))))
And then an action that uses this effect along with the effect that updates the store:
(nxr/register-action! :actions/navigate
(fn [state location]
[[:effects/update-url location (:location state)]
[:store/assoc-in [:location] location]]))
We will now dispatch the navigation action from the route-click
function:
(ns state-atom.core
,,,)
,,,
(defn route-click [e system]
(let [href (find-target-href e)]
(when-let [location (router/url->location (:routes system) href)]
(.preventDefault e)
(nxr/dispatch system nil [[:actions/navigate location]]))))
Next we’ll add the event listeners for body clicks and the back button. The back button handler will also update the store using an action:
(defn main [store el]
(let [system {:store store
:routes router/routes}]
,,,
(js/document.body.addEventListener "click" #(route-click % system))
(js/window.addEventListener
"popstate"
(fn [_]
(nxr/dispatch system nil
[[:store/assoc-in [:location] (get-current-location)]])))
,,,))
The final piece of the puzzle is to make sure routes are available as alias
data. The final main
function looks like this:
(defn main [store el]
(let [system {:store store
:routes router/routes}]
(add-watch store ::render
(fn [_ _ _ state]
(r/render el (ui/render-page state) {:alias-data {:routes router/routes}})))
(r/set-dispatch!
(fn [dispatch-data actions]
(nxr/dispatch system dispatch-data actions)))
(js/document.body.addEventListener "click" #(route-click % system))
(js/window.addEventListener
"popstate"
(fn [_]
(nxr/dispatch system nil
[[:store/assoc-in [:location] (get-current-location)]])))
;; Trigger the initial render
(swap! store assoc
:app/started-at (js/Date.)
:location (get-current-location))))
Now the app can generate links with the :ui/a
alias just like in the routing
tutorial. You can also trigger navigation with an action, which can be useful
after posting a form (to simulate a redirect), after completing login, etc.
As usual, the full source is available on Github.