6 things Reacters do that Re-framers avoid
I often hear JavaScript programmers complain about the complexity of React. "It's just another framework," they say. "It won't solve all of your problems." However, from the ClojureScript perspective, React solves the biggest problem, and the other problems are just normal programming problems. I wondered what everyone was complaining about.
I've poked my head out of the ClojureScript world and looked at what people were doing with React in JavaScript. I could see why they were having trouble.
In this article, I wanted to gather some of the React practices I've seen in the wild, show why they are problematic, and explain how we do it with Re-frame.
Table of Contents
- Reacters load data on mount
- Reacters register a bunch of event handlers in their components
- Reacters optimize when to render
- Reacters load data when stuff changes
- Reacters worry about when to set state
- Reacters use a lot of Redux boilerplate
- Conclusion
1. Reacters load data on mount
The React Lifecycle methods give you hooks into the complete life of a component, from initialization, through rendering, and finally unmounting from the DOM when it's no longer displayed. Of course a component is almost useless without the data it depends on. And if that data exists on a server somewhere, you're going to need to fetch it.
Reacters recommend starting the Ajax request to fetch the data in the
componentDidMount
method. That's the first method where you can
start setting the component state and have it trigger a re-render. The
callback on the Ajax request should call setState
with the data it
gets from the server. Until that request is completed, you can
indicate that it's loading, then when the re-render is triggered, you
show the data.
Here's why it's a problem:
As a functional programmer, this technique screams out side-effects. I want my Views to be pure. They need to take some data and output DOM. The pattern of firing an Ajax request when the component mounts means I can't feed it my own data (if it only knows how to get it from the server), or I have to have a way of indicating that I have the data. On top of that, it quickly becomes difficult to control how many Ajax requests are firing. I know that there are probably solutions to all of these critiques (maybe caching?), but already the complexity required to write this component is making me dizzy. That complexity should exist outside of the component. It belongs in the C(ontroller) not the V(view).
Here's the problem it's trying to solve:
This technique of loading data in the componentDidMount
is trying to
solve a real problem, which is that we want to tie the component to
the source of its data. You're saying "Whenever we show this
component, we need data from here which we fetch if we don't already
have it."
Here's how Re-framers do it:
In Re-frame, we keep the components, as much as possible, about rendering HTML. We don't want to do complex logic inside the component. And we certainly don't want to fetch data. Instead, they are just about converting data into HTML (using Hiccup).
So how do we solve that same problem, which is to tie a component with its data source? Well, one way to do it is to tie the display of a component to the fetching of data within an Event.
Let's say we have a component called app
that chooses which screen
to display based on a value from the Database.
(defn app []
(let [current-screen @(re-frame/subscribe [:current-screen])]
(case current-screen
:home-screen [home-screen]
:user-account [user-account]
...)))
We can create an event called :display-user-account
which sets the
current screen and fires off an Ajax to fetch the required data:
(re-frame/reg-event-fx
:display-user-account
(fn [cofx _]
{:db (assoc (:db cofx) :current-screen :user-account)
:http-xhrio {:url "/user-account"
:method :get
:success [:save-user-account]}}))
This Re-frame Event captures the intent that the user wants to see their account information and lists the Effects that should ensue. We solve the same problem (tying data loading and component mounting) without losing the purity of our components. Grouping of Effects should be done by an Event.
2. Reacters register a bunch of event handlers in their components
Let's face it: sometimes you need to register a window resize callback
and change the sizes of things with JavaScript. I have seen components
register their callback on window events in the componentDidMount
,
only to unregister them on componentWillUnmount
. Those are the
recommended places for doing those things.
Here's why it's a problem:
Like fetching data from the server, setting up callbacks is an effect. Those callbacks need to be managed. By putting the registration of callbacks inside the component, you're also cutting off your ability to guess how this component will work. Imagine making a giant list view where each element was one of those components. Are they each going to have a callback?
Here's the problem it's trying to solve:
There are components that legitimately need to change something based on a DOM event. Components need to resize or disappear when the window resizes. Or you want to have the component change color based on the window scroll event.
Here's how Re-framers do it:
Remember, in Re-frame, components are all about rendering HTML. They
shouldn't have to manage anything. When you're dealing with actions
from the user, like resizing or scrolling, you can capture that with
an Event like :resize-window
or :scroll-window
. So you would
register a callback that would dispatch a Re-frame :scroll-window
event in the window scroll event. Note that this doesn't apply to
events on individual DOM nodes. You might want to capture those using
local state.
Once you have the Event being dispatched, there are several ways to
implement the Event handler for it. Your components will need to react
to this event's changing data. For instance, the changing value is the
document.body.scrollTop
. You can put that in the database and
components can subscribe to it. They will be re-rendered whenever that
changes.
With this solution, you've only got one event handler that anything can react to. Being part of the database, you can also use it inside of any Subscription's calculations.
That said, you don't have to do it using Events and the Database at
all. You can simply create a Reagent Atom that always has the current
value of document.body.scrollTop
. Any component could deref
that
to get the current value and they'd re-render whenever it changes.
3. Reacters optimize when to render
One of the Lifecycle Methods is
shouldComponentUpdate
. You
can override this to help React know when a component doesn't need to
re-render, which could save a lot of processing. Reacters do this a
lot because they start to see slowdowns in their apps.
Here's why it's a problem:
I've got enough to worry about making my app work correctly. I don't think I could do as good a job at that if I also had to make it fast.
Here's the problem it's trying to solve:
JavaScript arrays and objects are mutable. If you're mutating an object in a way that doesn't need to be re-rendered, there's a lot of potential savings if you can figure that out.
Here's how Re-framers do it:
Re-frame capitalizes on the underlying power of immutable data structures. It's easy to know when immutable data changes. The answer is: never. If you've got an old value and a new value and the references are equal, you don't have to re-render. That's baked into Reagent. And that check is super fast. If they are different, you could check if they're equal, meaning they have the same value. Or you could just re-render and let React do its job of optimizing away non-differences. Reagent does one or the other, but honestly, it's so fast I've never bothered to check.
That takes care of what a Reacter would call prop
changes. There are
still changes that could cause a re-render due to Subscriptions
changing. The key there is that Subscriptions can be "chained" so that
one Subscription is calculated from the values of others. If one
Subscription in the chain calculates the same value it had previously,
it won't re-calculate anything down the line, and it won't trigger a
re-render. That saves another bunch of work. Subscriptions align
de-duplication with optimization.
4. Reacters load data when stuff changes
Another thing we see Reacters do is fetching new data when the props
change. They override
componentWillReceiveProps
,
check whether the thing they need has changed (such as the User ID in
the props
), and fetch the new thing from the server. The callback
will save the result in the state, causing a re-render. This is very
similar to the initial fetch in componentDidMount
except it's for
changes, not the initial load.
Here's why it's a problem:
Of course, this has the same problems as the initial load. It's mixing an effect with our pure component. It becomes difficult to re-use the component since wherever we use it, it could start doing Ajax requests.
Here's the problem it's trying to solve:
React is pretty helpful at making the smallest changes possible to a component instead of reinitializing the whole thing. For instance, if I have a component like this:
<UserView user_id={{this.state.user_id}} />
And the this.state.user_id
changes, React will simply send the new
props in instead of making a whole new component. That's great, but
now that the User ID has changed, that component will need the rest of
the data for that User.
Here's how Re-framers do it:
Re-framers do take advantage of this great property of React except that they don't load data in the component. They would likely use a Subscription and pass along the User ID as a parameter to it, like this:
(defn user-view [user-id]
(let [user @(re-frame/subscribe [:user user-id])]
...
That subscription would change value whenever the argument (user-id
)
changed, just like props
changing in React. That would trigger a
re-render. The problem still remains of fetching that new User from
the server. I would expect that whatever event triggered the user-id
to change would also fire off an Ajax to the server.
5. Reacters worry about when to set state
If you look through the lifecycle methods, you'll see that some of the
methods talk about whether you can call setState
within them and
whether the component will re-render due to such a call.
Here's why it's a problem:
There are nine lifecycle methods defined in React. That's a lot to keep track of. You have to know not only the purpose of the method, but what you're allowed to do in each one. Frontend programming is already really hard. I think React simplifies it tremendously, but I wouldn't want to have to think about whether it was safe to call a method at different times.
Here's the problem it's trying to solve:
Like I said before, React simplifies things a lot. DOM programming is really complicated, if you broke it down into its steps in a way that handled all situations. The lifecycle methods each have their place, and just before a re-render, it's not really appropriate to change the state. It's running full steam ahead trying to render what it already has.
Here's how Re-framers do it:
The solution is easy: we just never call setState
. We have other
ways of storing component-local data, using Reagent Atoms. And we have
other ways of storing global data, in the Re-frame
database. Everything is reactive, meaning that components re-render
when data they are looking at changes. We simply don't think about
when we're allowed to change stuff.
6. Reacters use a lot of Redux boilerplate
A lot of the functionality of Re-frame also exists in Redux. Redux is a way to build a system of action handlers which result in a reactive re-rendering of the React DOM. There is a data store and components are connected into it to make them re-render. I really appreciate Redux because it's bringing functional programming ideas to the JavaScript world.
Having used Redux in a project of some size, I know some of its pain
points. While it did help us clean up our code, it also had a lot of
boilerplate to set up. Each component needed to be individually
connected to Redux. We had to define, for each component, how the data
store was transformed into props
. Finally, we had to write our
action handlers in a very specific way and wire them up with arcane
function calls. It was a lot of work, easy to get wrong, and hard for
beginners to understand. The documentation was clear, but it was a lot
to learn.
Here's why it's a problem:
Like I said before, I really appreciate Redux for bringing functional
programming ideas to the mainstream. However, there were some serious
problems with it. Redux recommends building action handlers in a
certain way. That is, they check the action tag and if they know how
to handle it, they return the modified state, otherwise they return
the unmodified state. The current recommendation is to use a big
case
statement with each action type defined. There are ways to
break this apart based on different sections of your data store (keys
in the object), but you still need big case
statements, which I
believe are hard to read and they need to be centralized.
Further, Redux recommends separating your
components
into two types: Presentational Components and Container
Components. The presentational ones are like the ones you're
familiar with, but the container components are connected to the
Redux store and are subscribed to updates. To connect those
components, we define two functions called mapStateToProps
and
mapDispatchToProps
which transform the Redux state into props
and callbacks for the
connected component. There's just a ton of work to do just to get
things connected. Frankly, I think it's just too much, for beginners
and experts alike.
Here's the problem it's trying to solve:
Applications have complex data flows. We want to centralize the storage of data, yet allow arbitrary access to that data. Centralized storage means we can all share access. But the way it's organized centrally is not necessarily the way our UI components are organized. We need a good way to manage the data and a good way to get data out, all while staying reactive so that React can re-render appropriately.
Here's how Re-framers do it:
Re-frame is based on the same principles as Redux. However, Re-frame
takes more of a framework approach, while Redux is more like a couple
of libraries. For instance, Redux's documentation says your
application should have only one store
. However, what Redux gives
you is a function to create a store. Re-frame, on the other hand, also
says you should have one Database (the equivalent of a Redux store
),
but it creates it for you and you never have to think about it.
When you define Event Handlers (the equivalent of the parts of reducer
functions in Redux), you don't have to write a big case
statement. Re-frame handles the dispatch for you. You just have to
define what each Event type does. You don't have to do it
centrally. Each registered Event Handler could be in its own file, or
you can put them all in the same file. It's up to you.
Finally, there's no need to explicitly "connect" components to the Database. Reagent will automatically detect which components use which Subscriptions and have them re-rendered when the Subscriptions change.
The freedom from boilerplate makes Re-frame refreshing and quite pleasant to use.
Conclusion
React is great. But it's almost not a framework. It's very low level. It's great for building on top of. Specifically, it doesn't have any means of capturing user intent, no way to store application data, and no way to manage side effects. Because people think of it as a framework, Reacters have been trying to figure out how to solve all of those necessary features from within the confines of React itself, which gives you almost nothing more than the Component class.
Redux tries to give you a way to do all of those necessary features. But it's just got too much error-prone boilerplate. In short, it gives you a way to do all of those things, but that way is like threading a needle. You'll have to lick the end of that thread a lot before it will go in right.
I believe Re-frame, more than simply solving these problems, has made most of the problems non-issues for the programmer. That's the kind of thing I expect from a framework. As the joke goes "We had a state problem, so we added Redux. Now we have two problems." The Re-frame joke is "We had 100 SPA problems, so we chose Re-frame. Now we have five problems."
More posts in this Re-frame Series
- State in Re-frame
- The Re-frame Building Blocks Guide
- Guide to Reagent
- React Lifecycle for Re-frame
- Database Structure in Re-frame
- Re-frame, a Visual Explanation
- Optimistic Update in Re-frame
- Timeout Effect in Re-frame
- Why Re-frame instead of Om Next
- 6 things Reacters do that Re-framers avoid ← you are here