Recently, I have been digging into the latest and greatest developments over in the React world. A lot has changed with the release of Hooks and the looming release of Concurrent Mode.
A lot of these new developments make me think about how we want to approach optimizing our apps using the latest React features and ClojureScript.
In years far gone, immutability was a term that was thrown around a lot when talking about optimizing React applications. The React community mind share had landed on the idea that if you used immutable data structures in your app, your app would be faster.
This is a little curious at first glance. Typically, immutable data structures come with all sorts of performance penalties; you are necessarily paying a cost by copying certain information anew instead of just mutating the reference you have in your hand. Clojure does a lot to mitigate this performance hit, so much so that we use immutable data for almost all of our daily programming, but it is still a factor when weighing raw performance against mutable data.
However, once we dig into the details of what people were saying we can start to see why immutability might be buying us some extra cycles.
Typically, the most costly part of a React application is rendering. That is
not flushing to the DOM - that's called the commit phase and what React tries
to optimize the hardest - but actually executing the render
method / function
body of your component and then diffing it with the previous output.
The primary way we can optimize the render phase of our components is by telling React to not even bother running it. React exposes a number of tools for us to tell it that nothing will change if it renders our function, so it should just skip it.
For class components, shouldComponentUpdate
is the primary lever for
optimizing renders. shouldComponentUpdate
is a method that receives the
new props and state, and should return true
if React should call the render
method.
New in React 16.6 is React.memo
, a way of applying the same kind of
optimization to components defined as functions.
So the question then becomes: given the old state and props and the new state and props, how can we answer whether we should re-render as fast as possible?
If we can assume that a component should only ever re-render if it's state or props have changed, then that boils the question down to: How can we answer whether two values are different as fast as possible?
Comparing two primitives is something that we can all agree is probably fast.
Telling whether 1
is equal to 2
is never going to be a bottleneck for our
application, and is easy enough to write.
However, nested data structures like we often use to maintain state or configure components with can be quite costly to test. It might mean that we have to walk the entire data structure and compare each node between the two structures.
This is where our knowledge of immutable data structures kicks in. When we perform an operation on a data structure, we know that we will always receive a new object reference that contains the update data. It leaves the old object alone.
This leads us to our answer: if a value is always computed from the previous value, then we can simply do a reference equality check between the old value and the new value. If the references are different, then we can assume that the data has changed and we should re-render our component.
This is what I am going to refer to as a "fast yes" (props to @orestis for the term). Given the assumptions, we can be reasonably certain the two data structures are different and should trigger a re-render without doing a deep comparison. This is why being able to guarantee immutability of state and props when using React can be an optimization superpower.
However, there are instances where the initial assumption - that the previous value is used to compute the current value - is not true. For instance, imagine that a component is written like so:
(defnc SomeComponent [_]
[OtherComponent {:config
{:params
{:urls ["https://google.com" "https://yahoo.com"]}}}])
How does OtherComponent
know that the props passed to it are the same?
Because we have written them out literally, Clojure(Script) will actually
construct a new hash map each render of SomeComponent
. Because we reconstruct
the props each render, we cannot rely on reference equality to know that we
are receiving the same props.
If we want to optimize OtherComponent
to not re-render in this instance,
then we need to do a fully deep equality on the props map in order to ensure
none of the data has actually changed. That's what we were trying to avoid in
the first place!
Hence, this is the "slow no": in this case we take the slowest strategy to ensure that the data structures have not, in fact, changed. Immutability does not give us any speed advantages here.
This is where, as the author of our application, we have to pause and take
stock of the situation. How costly is re-rendering OtherComponent
actually? How might that weigh against doing a deep equality on the props
passed into it every time SomeComponent
is rendered?
We might also ask ourselves: why is SomeComponent
being re-rendered
repeatedly? Perhaps there is somewhere else up the component tree (e.g. some
state or props change) that could be optimized using a "fast yes", which would
make optimizing OtherComponent
far less important.
A common thing that I see people talk about in ClojureScript is wrapping all
of our components in React.memo
using clojure.core's default equality like:
(defnc OtherComponent [props]
;; wraps OtherComponent in React.memo
;; using = for deep comparison of props
{:wrap [(React/memo =)]}
[:ul (map (fn [url] [:li url]) (-> props :config :params :urls))])
However, this ensures that every time OtherComponent
is called in a render,
it will do a deep traversal of all of the props in order to determine
equality. This is probably undesired except in certain situations, situations
that the caller of OtherComponent
should be in control of.
As the author of a React wrapper for ClojureScript, I am not in any position
to know if any given component should be memoized, which is why I have not
wrapped every defnc
component in React.memo
.
It's tempting to try and prevent re-renders unless absolutely needed for everyone,
however it's difficult to do this generally. Instead, it might be better to
optimize it inside of SomeComponent
if we find that it's causing performance
problems.
(defnc SomeComponent [_]
(let [other-component (react/useMemo
(fn []
(hx/f [OtherComponent
{:config
{:params
{:urls ["https://google.com" "https://yahoo.com"]}}}]))
;; if OtherComponent is re-defined, re-render
#js [OtherComponent])]
[other-component])))
We might also realize that SomeComponent
probably doesn't need to be
rendered often, so we can instead optimize wherever SomeComponent
is called.
It's better to move up the UI tree and optimize where things are rendered.
This way we can avoid overzealously applying "optimizations" that actually
slow down our application!
Immutability is fast whenever we can compare two references and know that if they are different, then the data is different (a "fast yes"). Using immutable data is not any faster when we can not be reasonably certain that two references won't be equivalent values.
When it comes to optimizing applications, we should focus on optimizing hot paths in our UI tree and not go crazy trying to memoize every single component. At best we won't see much of a difference in most cases, and at worst it could actually end up causing a performance bottleneck.
If you're interested in exploring all of React's new features like =useMemo= with me, check out hx - a wrapper library for React that gives you easy access to the entire React ecosystem, React Hooks, Suspense, as well as a host of helpful tools.