Re-frame Tutorial with Code Examples

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

Re-frame is the most popular frontend framework for ClojureScript. It is built on Reagent, and thus React. Re-frame adds a beneficial amount of structure to build and maintain your frontend applications.

This Re-frame tutorial walks you through the main parts of the Re-frame's structure, how they work together, and how they help you succeed in the long run building your single page application.

Table of Contents

More posts in this Re-frame Series

Introduction

I have a hard task ahead. It's easy to list all of the features that a framework gives you and make it seem overly complicated. But everything in Re-frame is borne out of hard-won experience growing Reagent applications past the trivial demonstration stage. You will see similarities between Re-frame, Om Next, Redux, and the Elm Architecture. There is a remarkable consensus among these frameworks for how to architect a frontend application.

There are two ways I can approach the framework, and I can only choose one for this document. The first way is to list each feature of the framework, motivate it, and describe how to use it. You might hear someone mention a feature and you want to look up what it's for.

The second way is the inverse of the first: list each of the things you might want to do, motivate it, and describe which feature you should use. This second way is more use-case oriented. You know what you want to do, you just need to figure out how. I want to do both ways, but this document only does the first. I will write the inverse document soon. They should complement each other.

Event queue

Reagent really gives you just two things: a way to write Components with functions and a way to store state that those components can react to with Reagent Atoms. Reagent gives some great building blocks. But that's not much structure for building an app out of.

Re-frame gives you an Event Queue. A large part of building user interfaces is handling user events---button clicks, typing, mouse movements, etc. But to the user, a click is not a click. When a user clicks a "Login" button, their intent is different from when they click "Buy". Re-frame Events give us a way to name the intent of the click and handle different clicks differently.

You can add an Event to the queue like so, using re-frame.core/dispatch. We will alias re-frame.core to rf:

(rf/dispatch [:buy 32343])

Events are vectors. They are named by a keyword in the first position. You can attach other data to the Event after the name. In the case above, the :buy Event needed an item id to know what the user wants to buy.

Events in the Re-frame Queue are processed one at a time. We'll talk about how to define what the Event does in the next section. Before we get to that, though, let's look at how the simple indirection of naming Events clears up our Components. Here's a Reagent Component for a buy button.

(defn buy-button [item-id]
  [:button
   {:on-click (fn [e]
                (.preventDefault e)
                (ajax/post (str "http://url.com/product/" item-id "/purchase")
                  {:on-success #(swap! app-state assoc :shopping-cart %)
                   :on-error #(swap! app-state update :errors conj)}))}
   "Buy"])

That Component does quite a lot! It defines:

  1. How to calculate the product's purchase URL
  2. Where to store the item in the cart when a response comes back
  3. How to handle a request error

There is more code for handling these chores than for rendering HTML. Re-frame's Events help you separate out concerns. If your code is about rendering HTML, it goes in a Component. If it is about interpreting the user's intent or having effects outside of the component, it goes in an Event.

Using those guidelines, let's rewrite our Component:

(defn buy-button [item-id]
  [:button
    {:on-click (fn [e]
                 (.preventDefault e)
                 (rf/dispatch [:buy item-id]))}
    "Buy"])

That's much clearer, and it follows the recommendations. A callback should only dispatch one Event if it has an effect outside of itself. Now let's look at handling that :buy Event.

Handling events

Events in the queue are handled one at a time by Re-frame. I like to think of it more like it as interpreting the intent of the user. If we name our Events well, they might become timeless. An e-commerce site, for instance, will always want to know when a user intends to buy an item. That intent might be expressed as a click, a tap, a swipe, or even some other action we can only imagine as VR becomes more commonplace. Buying, however, is unlikely to change, since it is the main activity of our domain, e-commerce.

Another thing that could change is our backend. The URL we POST to could change. Or the data we send with it. Maybe we switch from a shopping cart model to a one-click buy button. We want to isolate these backend changes from the details of the UI.

We define the Handler for an Event using re-frame.core/reg-event-fx. Out of all the Handlers registered with Re-frame, it will use the one registered for the name of the Event. That's the right decision, because the name of the Event should capture the intent and is unlikely to change. The handler defines a pure function from the data attached to the Event to the Effects we want the Event to have. We'll get to Effects and how we define them really soon. Let's stay focused on the Event Handlers right now.

Here's how we can define how to handle the :buy event.

(rf/reg-event-fx ;; register an event handler
  :buy           ;; for events with this name
  (fn [cofx [_ item-id]] ;; get the co-effects and destructure the event
    {:http-xhrio {:uri (str "http://url.com/product/" item-id "/purchase")
                  :method :post
                  :timeout 10000
                  :response-format (ajax/json-response-format {:keywords? true})
                  :on-success [:added-cart]
                  :on-failure [:notified-error]}
     :db (update-in (:db cofx) [:cart :items] conj {:item item-id})}))

We register the Event Handler for all Events with the name :buy (the first argument above). If we register a different Handler for the same Event name, the first will be replaced. We only have one Handler for any given Event. The second argument is a function. It takes two arguments: the Co-effects (we'll get to that later) and the Event vector itself. We usually destructure it right in the argument list. We can ignore the name since we know the name already (it's :buy), so we usually just put a _. But we want that item-id.

W hat does this function do? Well, it just returns a map. It's a pure function. It takes in data and returns data. That's the way we like it! So how does this send a message to our server to add an item to our cart? This map that we're returning describes that Effect. An Event can have multiple Effects; this one just has two, namely :http-xhrio and :db. Each Effect is a key/value pair in the map the Event Handler returns. We like that the function is pure because we can test it much more easily and independently of our server.

But what are those darn Effects? Let's look at that now.

Effects

Effects are usually low-level details. They are ajax requests, storing data in the application state Database, or outputting to the console. These are what get things done. We've already built a piece of data that tells us what Effect needs to happen. Now we need to define how to handle it. We use re-frame.core/reg-fx.

We want to do an HTTP POST request. The data describes the parameters of the request.

(rf/reg-fx       ;; register an event handler
  :http-xhrio    ;; the name is the key in the effects map
  (fn [request]  ;; we get the value (a map) we stored at that key
    (case (:method request :get)
      :post (ajax/post (:url request))
      ...)))

As you can see, the Effect Handler defines how to carry out the Effect we put in that map. A lot of code is left out of that Handler, because the details are not so relevant right now. But if you're interested, there is a library that defines the :http-xhrio effect. I find that it's pretty good for doing ajax.

That's the ajax Effect, but what about the :db Effect?

Re-frame comes with a few built-in Effects. One of them is to reset the Database to a new value (I promise we'll get to the Database really soon). It's the way you should modify the Database to store application state. In this case, we're adding the item optimistically to the cart.

Optimistically? Yes. All of the Effects from an Event happen at about the same time. So the ajax Effect will fire (sending an HTTP request). Meanwhile, the Database Effect will fire, modifying the Database. By the time the ajax response comes back, the item will already be in the cart on the client side. If there was a problem, we'll have to remove the item. But 99% of the time, there is no problem. So we're optimistic. We just add it locally, assuming it will work. This is a common web frontend technique to make the app feel more responsive to user input.

Database Events

We saw the built-in Effect for modifying the Database. It turns out, in practice, that the vast majority of Events only have the one Effect of modifying the Database (we'll get to the Database really soon, I promise). All they do is read in the current value of the Database and modify it based on the data packed inside the Event.

Because it's so common, there's a shortcut. If all you need to do is modify the Database in your event, you can create a Database Event. Let's say you want to store the name of the current user in the Database:

(rf/reg-event-db ;; notice it's a db event
  :save-name
  (fn [db [_ first-name last-name]]
    (update db :current-user assoc :first-name first-name :last-name last-name)))

You just return the new value of the Database directly and Re-frame does the rest. Just to show how much shorter and clearer it really is, check out the equivalent using reg-event-fx:

(rf/reg-event-fx
  :save-name
  (fn [{:keys [db]} [_ first-name last-name]]
    {:db (update db :current-user assoc :first-name first-name :last-name last-name)}))

Okay, so it's not that much shorter :)

Before we get to the Database, we've still got one thing I said we'd get back to: Co-effects.

Keeping our Event Handlers pure

So far, our Event Handlers are pure. They take the Event (pure data) and the current value of the Database (pure data) and return a map of Effects (also pure). But what if you need something more? What if you need, in your Effect, to get the current time? You could call (js/Date.) to get the current time, but then you've lost the purity of you Event Handler. You can throw your testability certificate out of the window.

Re-frame does have a solution. Every Event Handler of the longer form gets a Co-effects map as the first argument. We've already grabbed the db out of that by destructuring it (see our :save-name code above). But you can have more stuff in there. For instance, you can ask Re-frame for the current time. Or you can get stuff out of LocalStorage. Or you can make an entirely separate database that you maintain and get its current value.

Each Event Handler can specify which Co-effects it needs using a special three-argument version of reg-event-fx. Let's imagine we need the current time to send to the server for adding an item to the cart:

(rf/reg-event-fx
  :buy
  [(rf/inject-cofx :now)] ;; add the co-effects we need here.
  (fn [cofx [_ item-id]]
    {:http-xhrio {:uri (str "http://url.com/product/" item-id "/purchase")
                  :params {:time (:now cofx)} ;; use the time
                  :method :post
                  :response-format (ajax/json-response-format {:keywords? true})
                  :on-success [:added-cart]
                  :on-failure [:notified-error]}
     :db (update-in (:db cofx) [:cart :items] conj {:item item-id})}))

Notice the second argument is now a vector. We're injecting the Co-effect into this Handler. What that means is every time the Event is handled, there will be the current time in the :now key in the cofx map.

You can define your own Co-effects Handlers.

Handling Co-effects

Alright! More definitions! I bet at this point, if you're reading this straight through, you're starting to think that Re-frame is pretty complicated. There are a lot of parts. And they work together in specific ways. However, I hope I'm making it clear that each one serves a very distinct purpose and it's easy to figure out which one you need for each purpose. I hope to make that clear soon. Right now, I just want to make a good reference.

Anyway, back to defining Co-effects. Co-effects have a key and a bit of optional data that they can use. Here's how we can define the :now Co-effect (note that it's already defined as a built-in in Re-frame).

(rf/reg-cofx
  :now
  (fn [cofx _data] ;; _data unused
    (assoc cofx :now (js/Date.))))

Bam! Just cram the current time right into the cofx map. That's the same map that you'll get in your Event Handler. When you test your Events, you could easily redefine this Co-effect Handler to return a fixed time.

Just for good measure, let's say you wanted to have a temporary id associated with that item added to your cart. Remember, we're doing an optimistic insert. It could fail on the server. We'll want to know what to remove in case it does fail. So let's give our item an id which we can then relate back when we get the response from the server.

First, we'll assume we already have the id in the cofx map.

(rf/reg-event-fx
  :buy
  [(rf/inject-cofx :temp-id)] ;; we'll define this later
  (fn [cofx [_ item-id]] ;; get the co-effects and destructure the event
    {:http-xhrio {:uri (str "http://url.com/product/" item-id "/purchase")
                  :method :post
                  :timeout 10000
                  :response-format (ajax/json-response-format {:keywords? true})
                  :on-success [:added-cart (:temp-id cofx)]       ;; start using temp-id
                  :on-failure [:notified-error (:temp-id cofx)]}  ;; here, too
     :db (update-in (:db cofx) [:cart :items] conj {:item item-id
                                                    :temp-id (:temp-id cofx)})}))

We're adding the temp id to both the success and the failure Events. We'll be able to relate them back. We also store the temp id with the item in the Database.

Now we can define this Co-effect handler:

(defonce last-temp-
id (atom 0))

(rf/reg-cofx
  :temp-id ;; same name
  (fn [cofx _]
    (assoc cofx :temp-id (swap! last-temp-id inc))))

We're using an atom to make sure the temp ids are unique, so we're definitely doing something impure. Data from impure sources should go in a Co-effect. We use the fact that swap! will return the new value of the atom. We increment it every time. And we store the temp id in the cofx map for the Event Handler to use. Easy!

But there's one more layer of turtles underneath . . .

Cross-cutting concerns

In our example above, we've actually got quite a few cross-cutting concerns. We've got a Co-effect that is adding in a temporary id. And there's an implicit Co-effect (added by Re-frame) that gives us the current value of the Database. All of these are functionality that are widely useful. We want to be able to implement them once and re-use them. In Re-frame, we can do that with Interceptors.

And, actually, everything having to do with Events is already Interceptors underneath. The reg-event-fx function is already defined in terms of Interceptors.

Interceptors are a funny name. But they're just a fancy way of creating a data pipeline. This is a detailed topic and you don't need to understand this to use Re-frame. Here's the picture.

Interceptor Diagram

Let me go through it one piece at a time. Each blue box is a function that takes a context and returns a context. A context is just data (and we'll see the format in just a minute). When an Event is handled, it comes in on the top, left-hand side. It goes through each of the functions in turn, each time being transformed. Then when all of the before functions are done, it rounds the corner and goes through all of the after functions the other way. There's no return value from an Event dispatch, so when the last function runs, it's done.

Notice the dotted lines connecting the before and after functions. Each Interceptor is a pair of one before and one after function. Your handler function, which you define when you register an Event, gets turned into a before Interceptor (with a no-op after function), which is the last Interceptor in the chain.

The Database is injected into the context in the first Interceptor. That is how the Database becomes available to your handler. Now, as we've seen, the handler returns a map of Effects. Those Effects are actually handled in the do-fx after Interceptor. All of these Interceptors are put in for you when you register an Event Handler with reg-event-fx or reg-event-db.

But notice there's an Interceptor with .... That's where extra Interceptors go. If you add in more Interceptors in your reg-event-, that's where they go.

So when you add a Co-effect, it's actually a before interceptor that adds to the context. Or you could add custom Interceptors.

Before we go on, let's take a look at the format of the context map.

{;; all of the values from the co-effects
 :coeffects {:event [:my-event "hello"]
             :db    {...}}

 ;; return value from your handler
 :effects   {:db        {...}
             :dispatch  [:event 1 2 3]}

 ;; the remaining interceptor functions to run
 :queue     (...)
 ;; the interceptor functions already run
 :stack     (...)
}

Remember, Interceptors are used for re-usable functionality, also known as cross-cutting concerns. One of those general concerns is reading in the Database value. Another is handling the Effects. A custom concern you could write is doing undo.

Let's do that in a really basic way. Here's how it's going to work. If you put the undo Interceptor in your Event handler registration, the Interceptor will save the current value of the Database in a Ratom. Then we can make an undo event that will restore the last version of the Database.

Let's make a Reagent Atom to store the last value of the Database. We'll store it in a list.

(def undos (r/atom ()))

Now we can make an Interceptor. We will only need the before function, s o we omit after.

(def undo-interceptor
  (re-frame/->interceptor
    :id :undo
    :before (fn [context] ;; we take the interceptor context
              (swap! undos conj (-> context :coeffects :db))
              context))) ;; return the context unmodified

This before Interceptor will get the current value of the Database (out of the context) and save it to the list in the Atom.

Now let's register that Undo Event.

(reg-event-fx
  :undo
  (fn [_ _]
    (let [undo-values @undos]
      (if (empty? undo-values)
        (do
          (js/console.log "No undo values, but :undo was dispatched.")
          {}) ;; return no effects
        (let [[f & rs] undo-values]
          (reset! undos rs)
          {:db f}))))) ;; update the db

Let's talk about it. First, we get the current value of the undos atom. If there are no undos (empty list), we write a message to the console (for debugging purposes) and do nothing. Otherwise, we get the last saved value (the first element of the list, f) and the rest (rs). We save rs back to undos. Then we set the db to the one we saved.

Now, I should say that it's not a good practice, in general, in application-level code, to make stateful Event handlers. But we're operating at such a low level right now. We're down in the pipes. This is where things like that happen. When you're in the pipes, you're going to get wet. But! You only have to do this in this one spot. It is possible to break it up into Effects and Co-effects, but for this purpose, where it's not a business domain capturing of intent, I don't think it's worth the complexity.

You can use this Interceptor by cramming it into your Event Handlers, like this:

(reg-event-db
  :add-triangle
  [undo-interceptor]
  (fn [db]
    (update db :shapes conj :triangle)))

When this Event is dispatched, the Undo Interceptor will run and save the current value of the database before a triangle is added. Then, when I dispatch [:undo], bam, the database will be replaced back to that last saved value.

We've talked about having effects on the outside world and getting data that is impure from the outside world. But we have only obliquely mentioned one of the biggest features of Re-frame: the application state Database.

So long and thanks for all the atoms

Reagent lets you create as many Atoms as you want to hold state. People experimented a lot with different schemes for keeping state. You could imagine as an example a system where the current user's information was held in one Atom, undo information held in another, and chat notifications held in yet another Atom.

This would totally work in a Reagent application. The nice thing is each thing is independent. Components can choose which Atoms they need to depend on and only be re-rendered when those particular Atoms change. The trouble is that you now have mutable state all over the place. You've recreated the problems of your forgotten, global mutable state past.

Re-frame makes the decision easy for you: you usually only use the database. Atoms are for component-local state and a couple of uncommon use cases. Application-global state goes in the centralized Database which it provides. Where to store your state is a big topic, so I wrote a separate guide about where to store your Re-frame state.

Because Re-frame has a place for application-global state, it can give you lots of convenient services around it. There is the Database Effect, :db, which we've already seen. It resets the value of the database. And the :db Co-effect gives your Event Handlers the current value of the Database. That is automatically added to all Event Handlers, because accessing the Database is so common. We've also seen the shorthand for Database Events, which don't have any effects besides modifying the Database.

The structure of your database

At some point you're going to wonder how to store stuff in your Database. Your Database is a map, so really you can put anything in it in whatever structure you want. But you usually don't know where you want it until you've experimented a little bit. Let's say on a first iteration, you put the shopping cart at the :items key at the top level. The db would look like this:

{:items [{:item-id 231}
          ... ;; other items
        ]
 ... ;; other stuff in the database
 }

But then you realize there's other stuff you need to store about the cart. You would love to move all of it to a nested map under the :cart key. Ugh! What a pain! How many places will you have to change? Will you have to go from Component to Component checking if it accesses the items? It's not so bad! Re-frame makes this kind of change much less painful than it would normally be because your Components don't reference paths in the Database directly.

Callback inferno

In Reagent, it was common to make your own centralized state using a single Reagent Atom. You could swap! whatever you needed to directly into the Atom. It was convenient, easy, and direct. However, there were problems.

Those problems wouldn't become apparent until your code grew big enough. The first problem was that the structure of your Database was defined all over the place---a little bit in each Component. Any callback function for a UI event might change something somewhere in the Database, and those callback functions were defined inline in the Components. On top of that, Components would dig out the values they needed from the Database right in their code. Components that added values to the Database were tightly coupled to Components that read those values out of the Database. Imagine the following two Components:

(defn increment-button [key]
  [:button
    {:on-click #(swap! app-state update-in [:counter key :value] inc)}
    "Increment"])
(defn counter-label [counter]
  [:span (get-in @app-state [:counter counter :value])])

Together they are very easy to understand. One button increments a nested value in the app state. The other displays it. However, imagine that they are in different files, each file with ten other Components, and you decide you need to change the location of those counters within the Database. Good luck finding all of the Components that access that exact key path ([:counter key :value]).

If you look at many Reagent Components, you'll notice a pattern: there are two kinds of Database access, writes and reads. Re-frame gives you Events for the writes and Subscriptions for the reads. So instead of looking through every Component, you only have to look through the Event handlers and Subscription definitions. That's much less code to look through and each one should be capturing the intent of what it does with a good name. Much more thought has gone into them than what typically happens with Components.

Structuring your Re-frame database is a big topic, so I've created a separate guide to Re-frame database structure.

So there are only two places we have to look to make structural changes to the Database: Event Handlers and Subscriptions. We've seen Event Handlers, but what are Subscriptions?

Getting data from the Database reactively

Reagent Components will re-render when the Reagent Atoms they deref are modified. If you put all of your state in one Atom and all of your Components deref that Atom, you're going to get a lot of unnecessary re-rendering, probably to the point of slowing down your application. You don't want to do that. Instead, you want to specify exactly what data the Component needs and only re-render if that data changes.

Re-frame gives you Subscriptions to do that. Subscriptions are parts of the data from the Database. Let's say you wanted to build a component to list the shopping cart items. You could define a Subscription to give you just the items out of the Database.

(rf/reg-sub
  :cart-items
  (fn [db _]
    (:items db)))

Then you would subscribe to :cart-items in your Component and deref it to get the current value. The Component will be re-rendered only whenever the cart's items change.

(defn cart []
  (let [items (rf/subscribe [:cart-items])]
    (fn []
      [:ul
        (doall
          (for [item @items]
            [:li {:key (:name item)} (:name item)]))])))

Notice how the Component does not need to know the details of the Database structure anymore. It only needs to know the name of the Subscription and what data it returns. This means that if you change the Database structure, you should not have to change your components. You will have to change some of you Subscriptions, however.

Let's move the cart items in the Database under the :cart key.

(rf/reg-sub
  :cart-items
  (fn [db _]
    (:items (:cart db))))

The Component should still work even though the Database's structure has changed.

Reactive de-duplication

Subscriptions can do a little more than that. Let's say we have another Component that shows us a cute shopping cart icon with a number of items in our cart. We can put this in the header on all pages.

(defn cart-icon []
  (let [items (rf/subscribe [:cart-items])]
    (fn []
      [:span [:img {:src "/cart.png"}] " " (count @items)])))

Does this Component really need to know all of the data in the cart? No. It only needs the count. If we swap items in the cart, this Component will re-render, even if the count stays the same. Plus, Components are for rendering HTML, not for doing calculations. Sure, this is a simple calculation, but let's move it out of the Component and into a Subscription.

(rf/reg-sub
  :cart-count
  (fn [_]
    (rf/subscribe [:cart-items]))
  (fn [items]
    (count items)))

Now we're seeing a way to chain subscriptions together. Instead of getting data out of the database, this Subscription gets data from another Subscription. First we name the Subscription, then we say how to generate the Subscription we need. The last argument is a pure function. The first argument is the current value of the Subscription defined above it. In this case, it's all the cart items. The return value will be the current value of this Subscription, here just the count. That last function will be re-run every time the :cart-items Subscription changes.

Now we can re-write our icon Component:

(defn cart-icon []
  (let [count (rf/subscribe [:cart-count])]
    (fn []
      [:span [:img {:src "/cart.png"}] " " @count])))

To be sure, it's not that much shorter. But it will be re-rendered much less and less calculation will be done overall. And this is one of the things I like about Re-frame: it aligns de-duplication and rendering optimization. Pull more calculation into Subscriptions and your app will be faster.

There's another form for the Reactive Subscriptions. Sometimes you want to re-combine two reactive values (like Subscriptions or Reagent Atoms), make a calculation from the two current values, and make a new subscription out of that.

Let's say we want to have some filter settings---like color, size, etc. We'll store those in the DB. Here's a Subscription that will get them out of the DB:

(rf/reg-sub
  :cart-filter-settings
  (fn [db _]
    (:filter-settings (:cart db))))

Now we want a Subscription that contains all the cart items that match the filter criteria. We can combine the filter settings and items subscription into one. Instead of returning a single subscription, we return a vector of them:

(rf/reg-sub
  :cart-items-filtered
  (fn [_]
    ;; return a vector of subscriptions
    [(rf/subscribe [:cart-items])
     (rf/subscribe [:cart-filter-settings])])
  (fn [[items settings]] ;; now this will be a vector of the current values
    (apply-filters settings items))) ;; actually do the filtering

When we return a vector of Subscriptions (or Reagent Atoms), our function will get a vector of the current values of those Subscriptions. In essence, we can combine any number of values from anywhere in the Database, and also from other Reagent Atoms that we might be using, into a single Subscription. Pretty cool!

Conclusions

I hope this romp through the features of Re-frame was enjoyable and educational. Re-frame gives you a way to capture the intent of UI actions (Events) and turn them into changes in the application state and the world outside of the browser (Effects). Further, it gives you a way to generate HTML based on changes to the application state (Components). You have a way to create interactive applications that present UI elements to the user, capture their intent, and change the UI based on that.

One of the things that I really like about Re-frame which I did not go much into is that it helps you organize your application. There is a place for everything you will want to do. With an easy decision, you can figure out exactly where to put each bit of your code. We'll go over that in another guide.

Another thing I like about it I did go over: Re-frame gives you lots of places to add meaning to your code. That is, it adds names throughout. Events are named. Effects are named. Subscriptions are named. Those names give you more semantic information and also give you just the amount of indirection you want to be able to easily make changes as you maintain your application over time.

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