Knowledge is only real when shared.
Loading...
Knowledge is only real when shared.
August 30, 2021
Introduction to state management with MobX.
React's model where a components is rerendered every time some data changes requires a state management library when data is to be shared between components and all components using said data should rerender automatically.
Although Redux and MobX were created at pretty much the same time, it was Redux that gained huge popularity early on and is still more popular. Redux is from an implementation standpoint very simple and once the basic model is understood is quite easy to use. However, it requires you to do a lot of tasks manually and therefore write more code for the same functionality.
It's still recommended to start with Redux but after a while move on to MobX which unfortunately has quite a steep learning curve and requires more experience to do what you want it to. The following introduction aims to get you started smoothly with whichever starting point you want to take.
State management has the goal to store data in the form of any basic JavaScript types and also to inform consumers (usually React components) about changes to the data they have access to.
Informing each consumer about every change to the data isn't feasible from a performance perspective. Therefore, state management tools have implemented different approaches on how to only inform the necessary consumers about changes. Of course the approach taken doesn't really affect what is easier to use, but is important to understand how to use it.
Both Redux and MobX require you to wrap any component accessing state with connect
or observer
. Without this Higher-Order Component wrapped around changes to the state will not result in a rerender of components. When connecting a component to Redux you have to manually specify all the properties of the state that the component can have access to. Redux passes these properties anew to the component every time something on the state changes. However, components will perform a shallow compare and only rerender if anything has changed. MobX on the other hand will not require you to manually specify which data should be passed to the component and a rerender will only happen if any of the data accessed has changed.
From a performance perspective both Redux and MobX are great. The performance can however suffer greatly if not implemented properly. Making sure that components stay small and only have access to specific parts of the state is most important and will usually resolve any performance related issues. In theory Redux is slightly faster than MobX however requiring the user to do everything by hand which significantly decreases developer performance. Performance should be considered a minor factor when deciding to use Redux or MobX.
This post is mainly about MobX but due to the prevailing popularity of the immutability approach with Redux it's concept will be explained in the following section that is shown by clicking Expand.
Below is an example of a dynamic React component displaying data from a MobX observable store. The following paragraphs will describe in detail what's happening.
The observable approach works differently in that it augments the data so that it can track which parts are accessed or updated. For normal usage the augmented data isn't distinguisable from it's original and can be used like regular data. However, in some cases one needs to make sure not to remove or currupt this observable layer on top of the data.
MobX has evolved a lot over the years unlike Redux where most changes and features came from additions to it's ecosystem. MobX used to rely on decorators which was a JavaScript proposal for annotations in classes as they have existed in Java for a long time. However, this technoloy didn't gain adoption in JavaScript and with version 6 of MobX decorators were removed. The newly introduced makeAutoObservable
helper makes decorators mostly obsolete anyways.
In the following example a simple store is set up. The newly introduced makeAutoObservable
is used and enables us to avoid assigning all the different types manually for every property. Setting autoBind
to true
will bind the this
context of the store to every action and so avoid a very common beginner mistake and cumbersome workarounds.
First the store is described in the form of a class. This later allows to create as many instances of this store as required. React components only need access to the store instances. Adding types with TypeScript in a store is highly recommended as it's easy to do while avoiding mistakes and making refactoring much easier.
Where MobX really shines is how it's connected to React components. All that has to be done is to wrap any component accessing data from an observable with observer
while any MobX store instances can be imported from anywhere.
Since the data on the Store Data
is now augmented and the React component is wrapped with the observer
function MobX will track what data is accessed during the render of this component and automatically trigger a rerender if any of the accessed data changes.
In cases where the new makeAutoObservable
doesn't work as expected you can override some types yourself or assign all of them yourself with makeObservable
.
All the data that's subject to change over time is considered an observable. It supports any basic types like strings or numbers as well as complex ones like nested objects or arrays. Observables should only be changed inside actions while state derived from the data should be accessed through computeds which will cache the results and inform observers when their result changes.
If actions don't have access to the store scope this will often lead to hard to debug issues. Therefore, it's important to bind actions to the this scope of the class. This can either be done by using action.bound
or enabling autobind
when using makeAutoObservable
.
Internal methods like useState
should be used when state is only required inside the current component or a set of components that clearly belong together. This approach quickly reaches it's boundaries where state is shared between different components and should be managed with a dedicated tool. The Context API allows to share state between lots of components but is much less flexible and requires more overhead to access.
The biggest drawback apart from the learing curve is the relatively big bundle size at roughly 60 kilobites which is about half the size of react-dom the main package required for react in the browser. Recently, other alternatives using the observable approach with proxies and tight react integration have popped up, but none have gained significant traction yet. valtio is worth mentioning as it works similarly to MobX but is much smaller.
These are the two approaches used to ensure that only the necessary components are rerendered after a change to the state is made.
For Redux to work properly it's necessary that the whole state structure is recreated after upon every change. Most commonly the ...
spread-operator is used to copy the parts of an object that remain unchanged while overriding the new properties.
In the above example the reference to settings
will be copied while for login
a new object is created and referenced. Therefore, if only the settings
reference is passed to a component it will not trigger a rerender as state.settings === nextState.settings
. For login
a new object has been created and so there referenced object changes even though inside only the login.authorized
variable has changed.
In order for changes to be picked up by all connected components it's necessary to always update all references up to the root with new objects. It seems like this would also lead to a lot of unnecessary rerenders but in practice this approach works fine as the parts to rerender decrease exponentially.
useState
with immer
Just like Redux's connect
, React's built in useState
also performs shallow reference comparisons that only work when using immutable objects. immer is a handy tool by the creator of MobX to apply changes made to a mutable object to another object in an immutable way. Like in the example above all object references up to the root will be updated for any nested change. Immer achieves the same by only making the necessary changes on a draft object which will then be turned into a new object with the same references updated as when updating the tree manually.
The useImmer
hook can be used to augment the basic useState
method in a way that changes can be made without creating a new object and only making the necessary changes before the update.
The second value usually returned by useState
now takes a method the same way as produce
from immer does. immer will ensure no changes will go unnoticed and the React component will always rerender on state changes.
MobX uses JavaScript Proxies to augment the data. Once it's augmented as an observable MobX can track any read or write to the values. Proxies weren't supported by many browsers for a long time. To work around this, MobX used objects with getters and setters to augment the data. This can still be enabled for compatibility with older browsers. Unless it's not supported by the browser MobX will use JavaScript Proxies. Some features are only available with Proxies. However, these limitations shouldn't discourage from using MobX with Internet Explorer 11.
When a React component connected to a MobX store with observer
is rendering MobX will track which values are accessed and will trigger a rerender in case any of those values are later changed by an action.
When using MobX often the case arises that one needs to attach a list of stores to a store. Whenever a new element is added to the list a new store needs to be initialized and then added to the list. To alleviate this issue I've created epic-mobx which will manage lists of stores for you.
In this case Item is another Store class and parts
will be filled with two instances of Item like [new Item(1), new Item(2)]
. Adding another one is now as easy as calling parts.extend(3)
. Additionaly, individual items can be removed without a reference to the parent like parts[1].remove()
.
Usually, in the constructor one has to manually assign all the different top-level variables to the store. epic-mobx also comes with a helper that automatically spreads properties on the store.