lilac.townWriting

When is immutability fast?

Published March 26, 2019
Last updated March 26, 2019

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?

Comparing two values

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.

Fast yes

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.

Slow no

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.

Where to optimize

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!

When is immutability fast?

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.

↑ Top