I saw this tweet from Dan Abramov highlighting the approaches of React compared to Vue:

98

As a library author I have explored implementing application state management from the perspective of value comparison and mutation tracking. In this article I will explain how these two approaches work and why I personally think one is better than the other for keeping UIs in sync with your application state.

Preface

In this article we are going to compare how two different approaches to detecting state changes affects how you structure that state, how you write logic to change that state and ultimately how it affects UI performance.

What is important to remember reading further is that immutability (value comparison) and proxies (mutation tracking) are tools that can be used to solve many different challenges in different environments. In this article we are discussing value comparison and mutation tracking to tackle the challenge of updating UI when state changes. Also note that when I say “UI update” I do not mean actually touching the DOM. I am referring to the work a component does to figure out if and what needs to be updated.

I will be using React in the examples, but you are not required to know anything about it to get value out of this article.

Value comparison explained

I remember immutability being a big question mark when I was first exposed to it. And even when I understood how it worked, I still did not understand what it was used for. To better explain immutability I will first explain how value comparison works and then how immutability makes this possible:

Imagine a simple component:

This component receives a user state and it renders the name and age of this user. That means whenever either the name or the age changes, we need this component to update. We could solve this without immutability. We could explicitly check if the name and or age has changed:

But manually optimizing all components like this would be tedious and fragile. As complexity increases it becomes increasingly difficult to debug when a component updates when it should not, and when it does not update when it should.

If our user was immutable we would no longer have to check the name and age. It would be enough to check if the user itself had changed. Let us see how we would update the age using a mutable and immutable approach:

If we used the mutable approach the user itself would not change, only the age property. With immutability we also create a new user object. And that is immutability in a nutshell. Bluntly put, whenever properties in objects or arrays change, also all the direct parent objects and arrays change.

In our component we can now make sure it updates by only comparing the old user value with the new:

React has no logic to optimize updates of components in itself, unlike Vue. It gives you the shouldComponentUpdate lifecycle hook to do value comparison. In combination with immutability this is a simple and effective approach to detecting when the UI needs to update.

Before we start talking about benefits and challenges, let us look at the other approach.

Mutation tracking explained

A radically different approach to the same challenge is allowing the developer to just mutate state as normal, but rather track what is being mutated. By using the new Proxy feature in JavaScript we track what state a component accesses and where mutations are made. By comparing these two pieces of information we know what components should update related to what mutations.

Unlike value comparison, mutation tracking is arguably more tricky to implement. That said, the implementation itself is hidden from the developer, unlike value comparison where you likely have to compare values manually to optimize updates.

With React as an example we need to introduce a wrapper to our components, which nicely highlights how it actually works:

Our User component is wrapped by logic that will take care of tracking state access. When the component renders we access two different properties on the user, name and age, this is stored by the trackStateAccess wrapper.

When our user is defined we do the same kind of wrapping, only for tracking mutations:

We now track state access and mutation and have the ability to figure out what mutations affects what components. If a component depends on user.name and we we do a mutation on user.name, they match.

Now that we have an idea of how the two different approaches work we can start to compare them.

State structure

Value comparison requires you to be aware of what state you bring into your components. The reason is that a nested change will cause a change to all direct parent objects and arrays. If you look at this example:

Every time you change the content of the file any component depending on workspace, workspace.files or workspace.files[0] will update. Due to this fact you will quickly meet the concept of state normalization. State normalization is not specific to the value comparison approach, but unlike mutation tracking you can get into performance issues if ignored. Specifically in this scenario you would want to change the structure to:

This makes sure that when file content changes it does not affect the workspace or the files array. Normalization does not fix the problem completely though. Any change to a file will affect any component depending on the file itself, even though it did not care about the actual property changed. It also introduces the need for additional logic that maps the ids of the files array to the dictionary of files.

Mutation tracking allows you to structure your state however you want. Since it tracks the exact state being used in components it does not matter how it is structured. With the example above only the components actually pointing to workspace.files[0].content would be affected by a change to the content. That does not mean you will never use normalization. For example users can be authors of posts, but also authors of comments. To avoid having duplicate versions of a single user in your state you can use normalization to ensure that when you change the name of a user, both the post and the comments will update. Unlike immutability you do not need to do this for performance reasons.

Conclusion is that value comparison causes an extra mental load in certain applications where you have to be careful about how state is structured, or you get into performance issues. Arguably you should always normalize your state, though it is not necessarily normalize-able state that causes the performance issue. With value comparison you will have unnecessary updates to components due to the fact that it is way too tedious to compare every single value. With mutation tracking you put your faith in the system. It relieves you of the mental load of figuring out how components update, it just works and optimally so.

Syntax

Value comparison can increase the amount of syntax needed to express state changes as JavaScript does not have Immutability built in. The example above can be improved by using spread and you can also use libraries like Immer to express immutable changes with a traditional mutable API. Immer actually uses proxies under the hood. As a library author Immer can be integrated in such a way that the immutable nature of the library is completely hidden.

Mutation tracking allows you to mutate your state as normal. There is really no difference when the tracking is integrated into the library.

One could argue that the mutation tracking approach encourages imperative style in your code, where immutability encourages a declarative functional style.

Conclusion is that syntax should never really be the deciding factor. We have different opinions about how to express code and that is perfectly okay!

Performance

Value comparison is very performant in the sense that there are really no implementation details related to components. We just compare values. That said, any state change will require every single component to verify if an update is necessary, unless you use a library with a lenses concept. If you are specific about the state needed in components you will normally be okay, but when you are not careful it can have huge implications. For example changing the text of an input in a complex form can easily cause the whole form to update, just because of the way the state is structured and used by other components.

There is also a cost to performing the state changes. Because all direct parent objects and arrays needs to be replaced you will consume more memory, which triggers the garbage collector more often. Immutable libraries uses structural sharing to aid the issue, but it will have a cost at the end of the day.

Mutation tracking uses proxies which has an overhead on accessing properties, it being for tracking state usage or state mutation. Just like immutability there are techniques that lowers the cost of this tracking.

Conclusion is that performance is incredibly hard to compare with two very different solutions. And there are also three completely different things that needs to be benchmarked:

Performance cost of mutations Performance cost of detecting change Performance cost of UI updates

The last point here is interesting. If mutation tracking has a higher cost on detecting change, but has less UI updates, how do you compare that? I have not done any benchmarking of this myself, and I think it is pointless. It depends on the application and there are so many other unrelated and more important types of performance metrics that affects your application.

Main conclusion and a release

Personally I favour mutation tracking for handling UI updates and these are my reasons:

Mutation tracking tracks exactly what state changes should affect the component. It automatically optimizes based on whatever state you access. Just knowing that value comparison causes a lot of unnecessary updates makes me feel out of control

Even though I favour a declarative functional approach to programming, you will always write imperative code to some degree. What I think is important is to have a clear separation. There is nothing preventing you to abstract the imperative code your write in a mutation tracking world into a declarative functional world

Mutation tracking is arguably a lot easier to get going with. And I deliberately use the term easy here. Our tools can be smart as hell, but the public facing API should not be

here. Our tools can be smart as hell, but the public facing API should not be Mutation tracking can produce more information about the relationship between changes and what is affected by those changes. Meaning you can get a report of your current state, what reacts to that state and what state changes has been made

With this last point, and really the whole article, I want to announce a tool for library authors called proxy-state-tree, which is currently in beta. It has implemented the logic needed to track state access and mutations, exposed with a simple API and debugging information. I built this with Fabrice (big hug to Fabrice) to help developers iterate on the really problematic parts of building web apps: “Where and how should state be defined and where and how should state be updated?”. How you detect the changes and update the UI should just work and be performant!

As an example of using this tool I built a demo library on top of Preact. It allows you to wrap your application in a Provider which receives state and actions. The state and actions are exposed to all connected components. What to take notice of in this simple demo is the implementation of components and devtools, as it shows how powerful and flexible the proxy-state-tree API is. With information from proxy-state-tree you can easily build a debugging experience that gives you a lot of insight into what is happening in the application. In the demo take note of the following: