Lately, I’ve been reflecting on the differences in the way data flows using ClojureScript’s Reagent library and using React with React Hooks.
First, an example of state in Reagent:
(defn greeting []
(let [name (reagent.core/atom “”)]
(fn []
[:<>
[:div “Hello, “ @name]
[:input
{:on-change #(reset! name (.. % -target -value))
:value @name}]])))
And an example of state in React Hooks:
function Greeting() {
let [name, setName] = React.useState(“”);
return <>
<div>Hello, {name}</div>
<input
onChange={ev => setName(ev.target.value)}
value={name}
/>
</>
}
While the two approaches have similar syntax, the implementation and semantics of the data flowing through your program are different in a key way.
It’s more likely than you think
The primary difference between reagent and React Hooks is where data is stored. Reagent stores your state inside of a container (atom) that you mutate directly, whereas React stores component state (including useState
) inside the immutable data structure that represents the entire structure of your application.
To show what I mean, I will give an example with Reagent that is not directly replicated using stock React Hooks.
(def name (reagent.core/atom “”))
(defn greeting []
[:div “Hello, “ @name])
The greeting
component will reactively re-render based on the change to the global name
atom.
What this demonstrates is that we can move the state value completely outside of the React application. State changes are coordinated and triggered based on a mutation to the name
atom.
When a mutation occurs (swap!
/ reset!
), the atom’s value will be changed immediately, and reagent’s machinery will trigger a state change in the components which depend on it. When the render occurs, the atoms value is dereferenced and is used to create the immutable tree that will be used to change DOM. There is a decoupling of the state of the atom and the state of the component.
State changes to components in React do not behave like this. When a component’s state is changed (for instance the “setter” function that is returned by useState
is called), it adds the update to a queue and schedules a render. On render, the internal state value is computed based on all of the queued updates, used to create the new immutable tree, and stored inside the tree for future reference. The state of the hook is coupled to the state of the component; the two can never be out of sync.
The distinction doesn’t seem important until we ask a specific question:
What if we wanted to pause while in the middle of rendering a component tree, and resume rendering later?
In that case, storing state in global containers whose changes aren’t coordinated by React become problematic.
Let me explain why we might ask this question.
The team that works on React is planning to release a new feature called Concurrent Mode. What Concurrent mode does is provide a way to pause in the middle of rendering, and resume it later. This can be used for various features that improve the UX of your application, such as pausing low-priority renders in order to handle higher-priority renders; they call this behavior time slicing.
An example would be a render triggered by a user action (e.g. typing in an input or clicking a button) would be higher priority than, say, a websocket streaming stock ticker data. The stock ticker can wait a few cycles before it updates, but latency on a button click or a keyboard press can be very detrimental to the user’s experience.
With concurrent mode enabled, React can assign updates triggered in event handlers as “high priority”. When a high priority update comes in, it will pause rendering the current immutable tree, and switch to rendering the immutable tree that corresponds to the higher priority updates in its queue, and then resume rendering the previous one.
“Ok, sounds cool; what’s the problem with atoms then?” I hear you ask.
The problem is two-fold:
Both can potentially be the cause of poor user experience, and neither is solved by passing down atoms via props or React Context. Both problems are due to mutating state without coordinating with React.
In the first case, either you always restart rendering from the parent component which the state update effected (doing extra work always), or you risk “tearing” - where different parts of your app have different versions of your state.
In the second, the state updates hogging the thread over higher priority updates might cause performance issues even when you would expect time slicing to help out.
Under Concurrent mode, problem 1 unfurls into subtle bugs that can be hard to capture and troubleshoot, as they will only be likely to occur under high load and/or low end devices.
This is why idiomatic React has no side effects in render that are not coordinated by React Hooks. This includes things like reading from external state, like dereferencing an atom.
There are tradeoffs to the React state model when compared to the Reagent model.
Having one component “own” a piece of state means that if two components need the same state, a shared parent must pass it down to both components, either through props or React Context.
This can cause performance issues where state required by two deep, distant leaf nodes triggers a re-render of a large component tree. In the Reagent model, only the two components that depend directly on the state would re-render, not the whole tree that contains them.
More subjectively, this can make it difficult when architecting an app because it forces you to choose a component to manage each state. The act of lifting state after the fact can be mechanically onerous. An argument can be made that because it’s hard to know where to manage each state, it’s better to put it in a global like a reagent atom, re-frame db, redux store, instead.
I am interested in exploring how adopting the mainstream React paradigm of non-side-effecting renders changes the experience of both developing and using apps built with ClojureScript.
There are other mainstream frameworks that are continuing to invest in the mutable, reactive-atom approach in JS land such as Vue and Svelte.
If you’re interested in exploring this with me, check out helix, a different approach to using React with ClojureScript.