The Hidden Lessons in a re-frame App
by Laurence Chen
I took over a web application whose frontend was built with re-frame, and not long after I started working on it, I felt a bit of discomfort. So, I decided to investigate the source of that discomfort. And my first suspect was re-frame.
The reason I had this suspicion was mainly because in the frontend stack most commonly used at Gaiwan, we usually only use Reagent.
So let’s start with a simpler example comparison—maybe that will shed some light.
Understanding the Abstraction Cost of re-frame Through an Example
Feature description: Display a button and a number. Each time the button is clicked, the number increments by one.
- Counter implemented using Reagent
(ns example.core
(:require [reagent.core :as r]
[reagent.dom :as rdom]))
(defonce counter (r/atom 0))
(defn counter-component []
[:div
[:p "Count: " @counter]
[:button {:on-click #(swap! counter inc)}
"Increment"]])
(defn ^:export init []
(rdom/render [counter-component]
(.getElementById js/document "app")))
- Counter implemented using re-frame
(ns example.core
(:require [reagent.dom :as rdom]
[re-frame.core :as rf]))
;; -- Event Handlers ---------------------
(rf/reg-event-db
:initialize
(fn [_ _]
{:count 0}))
(rf/reg-event-db
:increment
(fn [db _]
(update db :count inc)))
;; -- Subscriptions ----------------------
(rf/reg-sub
:count
(fn [db _]
(:count db)))
;; -- View Component ---------------------
(defn counter-component []
(let [count @(rf/subscribe [:count])]
[:div
[:p "Count: " count]
[:button {:on-click #(rf/dispatch [:increment])}
"Increment"]]))
;; -- Entry Point ------------------------
(defn ^:export init []
(rf/dispatch-sync [:initialize])
(rdom/render [counter-component]
(.getElementById js/document "app")))
After comparing the two, I found it difficult to conclude that “re-frame was the reason I felt uncomfortable.”
It’s true that when first using re-frame, the code seems to get longer. However, considering that the project I’m maintaining is a multi-page web application, the fact that re-frame requires you to extract pieces like subscriptions and event handlers from view components isn’t really a problem—because even without re-frame, such extractions would still be necessary in a project with multiple pages and complex UI components.
So, I decided to change my approach and directly investigate the discomfort by examining the actual development challenges.
The Difficulty of Locating Code to Modify
When taking over a new project, the earliest phase is often the one where I use grep
the most. Due to a lack of familiarity, even if I have a clear idea of the feature I want to implement and how to implement it, I still spend time locating where the code needs to be changed.
Let’s take a UI workflow modification example to illustrate:
Refer to the image below. In this workflow, the user clicks two different submit buttons. Clicking Submit Button 1 opens a modal page to collect more detailed information from the user, and shows Submit Button 2. When Submit Button 2 is clicked, the collected information is sent to the backend to trigger a chain of subsequent actions.
Now consider a required modification: “Due to changes in backend logic, simplify the two-step submission into a single-step submission.”
Here’s how I approached the actual task:
- Skim through the view page code, find the event name dispatched by Submit Button 1, and link the view page to Event Handler 1.
- Skim through the modal view code, find the event name dispatched by Submit Button 2, and link the modal view to Event Handler 2.
- I needed Submit Button 1 to dispatch an event that includes all the data that was previously only gathered during the Submit Button 2 dispatch. So, besides finding where the modal view dispatches Event 2, I also had to trace all the subscriptions used in that modal view. Also, I had to examine the subscriptions associated with Submit Button 1.
- It was during this “tracing upward from dispatch to subscriptions” process that I felt a high cognitive load. I often had to go up one to three levels of function calls to locate them. Also, due to similarities between the view page and the modal view code, it was easy to get confused.
- After mentally organizing the cognitive load from steps 1–4, I was finally able to start rewriting the code.
Mixing of Concerns: The Real Source of Complexity
Only after carefully analyzing the development process and recognizing moments of elevated cognitive load was I able to articulate my challenge in code.
Here’s what I encountered:
(defn modal-form [{:keys [data3]}]
(let [data4 @(rf/subscribe [:user/data4])]
[:div.modal
...
[:button {:on-click #(rf/dispatch [:event2 {:data3 data3 :data4 data4}])}
"Submit"]]))
(defn submit-button1 [data1]
...
(let [data2 @(rf/subscribe [:user/data2])]
...
[:button {:on-click #(rf/dispatch [:event1 {:data1 data1 :data2 data2}])}
"Submit"]))
(defn show-list-table [{:keys [data1]}]
...
... ;; table data
...
[submit-buttion-1 data1])
(defn main-page []
(let [other-states @(rf/subscribe [:ui/other-states])
should-show-modal @(rf/subscribe [:ui/show-modal])]
[:div
(when should-show-modal
[modal-form other-states])
[show-list-table other-states]]))
In this code structure, you can see that rf/subscribe
and rf/dispatch
are scattered throughout many components. Especially when modal-form
and submit-button1
are relatively small components, each still handles its own subscriptions and dispatches, making the control flow difficult to follow. Readers have to understand the UI, its state dependencies, and the resulting triggered events—all at once. This mixture of concerns increases cognitive load significantly.
Design Pattern for Separating Concerns
Fortunately, this kind of cognitive load is improvable. The solution is the Presentational/Container Components Pattern.
By applying this pattern, we separate data sources from UI rendering. In short:
- Presentational components focus only on how things should look. They read data from arguments, are unaware of where the data comes from, and hand off user events via injected handlers.
- Container components focus on what data to show and what events to dispatch when the user interacts. They handle state and behavior.
After applying this pattern, the code would be refactored as follows:
;; Presentational Component (no subscribe)
(defn submit-button1-view [{:keys [data1 data2 on-submit]}]
[:button {:on-click #(on-submit {:data1 data1 :data2 data2})}
"Submit"])
(defn modal-form-view [{:keys [data3 data4 on-submit]}]
[:div.modal
...
[:button {:on-click #(on-submit {:data3 data3 :data4 data4})}
"Submit"]])
;; Container Component (only state and dispatch)
(defn show-list-table-container []
(let [data1 @(rf/subscribe [:user/data1])
data2 @(rf/subscribe [:user/data2])]
...
... ;; table data
...
[submit-button1-view
{:data1 data1
:data2 data2
:on-submit #(rf/dispatch [:event1 %])}]))
(defn modal-form-container []
(let [data3 @(rf/subscribe [:user/data3])
data4 @(rf/subscribe [:user/data4])]
[modal-form-view
{:data3 data3
:data4 data4
:on-submit #(rf/dispatch [:event2 %])}]))
;; main-page
(defn main-page []
(let [should-show-modal @(rf/subscribe [:ui/show-modal])]
[:div
(when should-show-modal
[modal-form-container])
[show-list-table-container]]))
Conclusion: Correctly Applying re-frame Is More Than Just Using the Framework
Looking back on the whole process, re-frame itself wasn’t actually the root cause of my initial discomfort. Its event-driven architecture and data flow offer real benefits for large, state-heavy applications. However, when view components mix data access (via subscriptions) and behavior logic (via dispatch), even simple UI tasks can create high cognitive burdens for developers.
What I really struggled with was the lack of proper separation of concerns. This isn’t a problem unique to re-frame; any frontend architecture can suffer from it when poorly organized.
Thus, my biggest takeaway is:
When using re-frame, consciously applying the Presentational/Container Component pattern can significantly reduce cognitive load and improve the predictability and maintainability of your code.
Coming back to re-frame itself: its event and subscription mechanisms are designed to help developers build predictable, testable, and composable applications. But whether these mechanisms fulfill their potential depends on whether developers use them in a structured and intentional way.
re-frame’s design is well-suited to complex systems. But for that very reason, it reminds us:
The more complex the system, the more we need to actively create good structure, instead of expecting the framework to make the right decisions on its own.