Skip to Content
Technical Articles

UI5 reactive state management with MobX state tree

Update: Caspár van Tergouw and myself gave a talk at UI5con 2019 about Mobx state tree. You can find it on YouTube.

—————————————————–

There already have been a couple of articles dedicated to reactive programming in UI5. The concepts of reactive programming are interesting for any programmer, because when applied correctly, they will lead to more maintainable code, or as put quite nicely by this article about using MobX in UI5:

The benefits of this approach are a better separation between UI and business logic, easier testable code, eventually less number of bugs and in some cases even better performance of your app.

MobX is a popular reactive state management library, coming from the React-ecosystem. It has a relative low learning curve. There are just a few basic concepts behind MobX and they are generally easily grasped by a JavaScript developer. MobX also matches quite nicely with UI5, because when applied correctly it can add the above benefits to a UI5 code base, while not being ‘in the way’.

When applying ‘vanilla’ MobX however, there is a risk of losing the overview in your code. Since it is such a flexible library, it is easily inserted anywhere (e.g. in the controller). One can easily get confused when reactions are popping up here and there without any clear logic to it. This will make understanding and debugging the code a tough job, defeating the whole purpose of adding MobX.

In this article, I won’t further introduce MobX and how to use it inside of UI5, since this is already explained nicely in the earlier mentioned article. Instead, I will introduce MobX state tree, a library built on top of MobX, which adds structure to MobX. It also has a couple of other key features that may be interesting. In this article, I will introduce these features and their advantages. I will also propose a structure for a project using MobX state tree and close off with some lessons we learned by using MobX state tree in a UI5 app that is now in production with over 500 users.

Key aspects of MobX state tree

  • Specification of data with models
  • Strict datatypes
  • Data is protected and can only be changed through actions
  • Defining derived data
  • Easy serialization and deserialization

Specification of data with models

With MobX state tree, the key thing you do is create ‘models’ for your data. As a UI5 developer, you are already familiar with the concept of models, since you will certainly have used a JSONModel or an ODataModel. The main difference between the concepts is that a model in MobX state tree is just a specification while UI5 models contain data.

With MobX state tree I can create a very simple model like this:

// mobxStateTree is available as a global

// create a shortcut variable for creating types
const types = mobxStateTree.types;

const Book = types.model("Book", {
        name: types.string
    });

When I want to create an instance of a model, I call on the ‘create()’ function.

const book = Book.create({name: "Moby Dick"});
console.log(book.name); // "Moby Dick"

Just defining your data with models will already add structure to the code base. You will be forced to think about the entities that live in the application and the properties that they have.

Strict datatypes

In the previous example you might have noted that the ‘name’ property was specified as a string. This is something essential to MobX state tree. You always need to specify exactly what type your data will be.

Let’s expand the model a little bit:

// create a shortcut variable for creating types
const types = mobxStateTree.types;

const Book = types.model("Book", {
        name: types.string,
        dateReleased: types.Date,
        amountSold: types.integer,
        summary: types.maybe(types.string)
    });

In this example, we added the ‘dateReleased’ property (a JavaScript Date), the ‘amountSold’ property (an integer) and the ‘summary’ property (a string). The ‘summary’ is optional and might therefore be undefined. All other properties are required (but a string may still be an empty string).

When using MobX state tree, you can be assured that a certain property will always conform to the defined type. So, when a property is required, it will be there and when a property is defined as a string, it will never change to a number.

Perhaps you can imagine the advantages of having a strict data type. No need to defensively check datatypes and no confusing bugs because a variable changed from a number into a string somehow. MobX state tree guarantees that the property will always comply to the specification.

Here is an example of instantiating our model now. Note that I don’t need to specify the summary because we specified it as an optional property.

const book = Book.create({
        name: "Moby Dick",
        dateReleased: new Date(), // now
        amountSold: 0
    });

console.log(book.name); // "A book"

note: actually, in production builds of MobX state tree (which we use in UI5) you do need to add a ‘type check’ to be absolutely sure data input complies to the specification – I will explain this in the end of the article.

Data is protected and can only be changed through actions

The reason that MobX state tree can guarantee that objects conform to the specification is because the data inside of the model is protected.

For example, this will raise an error:

const book = Book.create({
        name: "Moby Dick",
        dateReleased: new Date(), // now
        amountSold: 0
    });

console.log(book.amountSold); // 0

book.amountSold++; // Try to increase to 'amountSold' property by one

// Error: [mobx-state-tree] Cannot modify 'Book@<root>', ...
// ...the object is protected and can only be modified by using an action.

There is only one correct way to change an object and that is through actions. Therefore we need to expand the ‘Book’ model with an action:

const Book = types.model("Book", {
        name: types.string,
        dateReleased: types.Date,
        amountSold: types.integer,
        summary: types.maybe(types.string)
    })
    // I use modern syntax here, but you could also use oldschool javascript
    .actions(self => ({
        addSoldBook() {
            self.amountSold++;
        }
    }))

Now when we use this action after instantiating the model, it will increase the ‘amountSold’:

console.log(book.amountSold); // 0
book.addSoldBook();
console.log(book.amountSold); // 1

The advantage of this protection is once again that you get certain guarantees. You know from the definition of the model that the only way the ‘amountSold’ property is changes anywhere in the codebase is by the use of the ‘addSoldBook()’ method. There is no other way of changing this property!

Think of it as some kind of protected package. There is no way to change anything inside of the package, except by some knobs and buttons that have been places on the surface. No need to worry that anyone has looked inside the box and been messing around with the stuff inside.

Yes, this makes two-way binding impossible, which you are used to when using a JSONModel. All changes must go through the controller. This is however the whole point because it makes the code more maintainable. Further down, we will show how the data can be changed by the controller.

Defining derived data

When defining a model, you can define derived information from it, called ‘views’ in MobX state tree (not to be confused with views in UI5). Think of these as lenses through which you look at the data. By using MobX, these derived values will be automatically updated whenever the data changes. So there is no need to code triggers and watchers yourself.

For example, we can create a new read-only property that will define whether the book is a bestseller. We call a book a bestseller when it has sold more than 10 times.

const Book = types.model("Book", {
        name: types.string,
        dateReleased: types.Date,
        amountSold: types.integer,
        summary: types.maybe(types.string)
    })
    .actions(self => ({
        addSoldBook() {
            self.amountSold++;
        }
    }))
    .views(self => ({
        get bestseller() {
            return self.amountSold > 10;
        }
    }));

The ‘bestseller’ property will now always yield true or false.

const book = Book.create({
        name: "Moby Dick",
        dateReleased: new Date(), // now
        amountSold: 9
    });

console.log(book.bestseller); // false
book.addSoldBook(); // add one more sold book, making it 10
console.log(book.bestseller); // true

When you have a property describing whether a book is a bestseller, it becomes a lot easier to filter a list of books by this property. If you ever have been struggling with summing up certain things from a list or filtering objects in a list, you will really like this feature. It is actually something that is essentially MobX, but MobX state tree gives you a standardized structure to do it.

A big advantage of modelling derived values this way is that the definition of a ‘bestseller’ is now clearly defined in the model. Whenever a team member wonders when a book is defined as a ‘bestseller’, the best place to look is at the model definition. Without using this pattern you might be tempted to put this logic in a controller or perhaps a formatter. This would however make it less intuitive to find. Now the logic just sits right into the definition of the model itself instead of anywhere else in the code base.

Easy serialization and deserialization

One of the powerful features of MobX state tree is the fact that it is quite easy to ‘pack up’ your data for transferring it and using it somewhere else. This concept is called ‘serialization’. Basically, it is the difference between the ‘living’ data in your app and the representation in JSON.

The other way around, creating objects from their representation in JSON is also quite straightforward with MobX state tree. This is what is meant with ‘deserialization’.

In MobX state tree, the result of serialization is called a ‘snapshot’. At any moment you can create a snapshot from your data by using ‘getSnapshot()’. You can also subscribe to all changes and get a snapshot for each change by using ‘onSnapshot()’.

To create an object from a snapshot, you can use the ‘create()’ function of a model and use the snapshot as an input parameter. Changing an existing object is done with ‘applySnapshot()’.

The really cool thing about applySnapshot() is that MobX state tree will precisely detect what has been changed and leave everything else as it is. In the app I mentioned in the introduction, we use this feature to update a collection of (pretty complex) object in the app. In a previous implementation without MobX state tree, the whole array of objects was renewed every time we refreshed the data in the app (which is done often). This triggered a re-rendering in the UI5 views and it also made it hard to build triggers that could detect changes inside an object, since the whole object was renewed every time.

With MobX state tree, the objects in the array will be re-used and changes are only applied to properties that have truly been changed.

const book = Book.create({
        name: "Moby Dick",
        dateReleased: new Date(), // now
        amountSold: 9
    });

// get a snapshot of the current state
const snapshot = mobxStateTree.getSnapshot(book);

// get a snapshot whenever the state changes
mobxStateTree.onSnapshot(book, (snapshot) => console.log(snapshot));

// apply a snapshot to update the current state
// First edit something on the snapshot 
// I can edit this property since it is just a javascript object and not a Mobx state tree object
snapshot.name = "Something else"; 
// Only the 'name' property will now be edited. 
// MobX will not trigger a change on the other properties.
mobxStateTree.applySnapshot(book, jsonData);

// create a new object from a snapshot
Book.create(snapshot);

Structuring a UI5 app with MobX state tree

Now that you know some of the key features of MobX state tree, I will show how you can use UI5 modules to structure the models in a UI5 app. I will introduce the concept of ‘stores’ and show you how the data living in those stores can be used in your views and controllers.

Structuring your models

I think that generally a model in MobX state tree should have it’s own module (and therefore it’s own file). Let’s create a module in a file called Book.js, which sits in a new ‘model’ directory:

model/Book.js

// global mobxStateTree
sap.ui.define([], function() {
    
    // create a shortcut variable for creating types
    const types = mobxStateTree.types;

    const Book = types.model("Book", {
        name: types.string,
        dateReleased: types.Date,
        amountSold: types.integer,
        summary: types.maybe(types.string)
    })
    .actions(self => ({
        addSoldBook() {
            self.amountSold++;
        }
    }))
    .views(self => ({
        get bestseller() {
            return self.amountSold > 10;
        }
    }));
    
    return Book;

});

When expanding your app, create as many models as you need. These can be small or big and they can also reference one another by adding one model as a dependency to another. Read the MobX state tree documentation for more information about references and building trees of models.

Using a store to store your data

When reading up on MobX and MobX state tree, you will discover the concept of a store for storing data, such as in this MobX best practice guide. A store is the place where your data will live and you can have as many stores as need in a single app, depending on the complexity of your app.

For example, we can create a book store module like this. Besides just a list of all books, we have also created a list of all bestsellers (a view) and we define an action called addBook(). Note that we use a new data type for the collection of books, called types.array.

(Also note that I do a typecheck() when adding a book. I will explain why this is necessary at the end of the article.)

stores/bookStore.js

// global mobxStateTree
sap.ui.define(["../model/Book"], function(Book) {
    
    // create a shortcut variable for creating types
    const types = mobxStateTree.types;

    // Definition of the Book Store
    const BookStore = types.model("BookStore", {
        books: types.array(Book) // the 'Books' property is an array of books
    })
    .actions(self => ({
        // a function to add a book to the list
        addBook: function(book) {
            // typecheck() throws an error when the input does
            // not comply to the specification
            mobxStateTree.typecheck(Book, book); 
            self.book.push(book);
        }
    }))
    .views(self => ({
        // a list of bestsellers
        get bestsellers() {
            return self.books.filter((book) => book.bestseller === true)
        }
    }))

    // Instantiate the Book Store
    const bookStore = BookStore.create({
        books: [] // we start with an empty array
    });
    
    return bookStore;

});

What you might notice is that we instantiate the store inside the module. This is something we preferred while experimenting different approaches. The advantage of using this approach is that you create just one store in your app, which you can reference anywhere by using the UI5 module syntax. This is known as a singleton.

For example, now we can use the store in a controller:

controller/Main.controller.js

sap.ui.define([
    "sap/ui/core/mvc/Controller",
    "../stores/bookStore"
], function(Controller, bookStore) {

    // you can now use bookStore anywhere you want
    // for example, to add a book use bookStore.addBook()

    return Controller.extend("MobXExampleProject.controller.Main", {
        // ...controller methods...
    });

});

I hope you notice how structured and self-explanatory the code is. You now have a function to add a book, which pushes a new entry into the array while the bestsellers list shows you all the books that are bestsellers.

Let me emphasize that the list of Bestsellers is always kept up-to-date, so there is no need to refresh this list yourself. Mobx will take care of it.

Using MobXModel to bind stores to UI5 views

Of course, this story would not be complete without the part where we actually show the data to the user inside a UI5 view. Fortunately, this is easy due to the use of MobXModel, which creates a bridge between MobX and the UI5 concepts of models and bindings.

First, we need to create this MobXModel and then set it as a named model to the component so it can be used in all the views. For this we installed the library for MobXModel inside a new folder ‘mobx’ inside our application.

Component.js

sap.ui.define([
	"sap/ui/core/UIComponent",
	"MobXExampleProject/mobx/MobxModel", // Require the MobxModel
	"MobXExampleProject/stores/bookStore" // Require the bookStore
], function(UIComponent, MobxModel, bookStore) {
    "use strict";
    
	return UIComponent.extend("MobXExampleProject.Component", {

		metadata: {
			manifest: "json"
        },
        
		init: function() {

            // Create a new model from the bookStore
            var oModel = new MobxModel(bookStore);
            // Set the model on the component as a named model
			this.setModel(oModel, "bookStore");

			// call the base component's init function
			UIComponent.prototype.init.apply(this, arguments);
		}
	});
});

Now let’s show all the bestsellers in a table:

view/Main.view.xml

<mvc:View controllerName="MobXExampleProject.controller.Main" xmlns:mvc="sap.ui.core.mvc" xmlns="sap.m">
	<Table headerText="Bestsellers" items="{bookStore>/bestsellers}">
		<columns>
			<Column width="25%">
				<Text text="Book"/>
			</Column>
			<Column width="25%">
				<Text text="Author"/>
			</Column>
			<Column width="25%">
				<Text text="Sold"/>
			</Column>
			<Column width="25%">
				<Text text="Summary"/>
			</Column>
		</columns>
		<items>
			<ColumnListItem>
				<cells>
					<Text text="{bookStore>name}"/>
					<Text text="{bookStore>author}"/>
					<Text text="{bookStore>amountSold}"/>
					<Text text="{bookStore>summary}"/>
				</cells>
			</ColumnListItem>
		</items>
	</Table>
</mvc:View>

Let me emphasize that you don’t need to worry about refreshing the data in this Table. Because we are using MobX, the user will always be looking at the current state.

Lessons from applying it in an app

Here are some of the lessons we learned while applying MobX state tree in an app:

Versions and browser compatibility

In UI5, you need to use the packaged build from MobX and MobX state tree, so it will be loaded as a global variable. In most of the examples of the documentation, you will find that they use javascript module syntax and a node.js backed development environment. But as to my knowledge this is not compatible with the way you develop a UI5 app (or at least it is in the version we use, which is 1.52).

When using the packaged build, it is standard in production mode. This means that you will need to ‘typecheck’ your data when instantiating your models, as I also showed in the examples. This was not clear to us from the start, I had to raise an issue in Github to learn this fact.

It is recommended to use the arrow function syntax (() => {}) for more clarity if you can, but you can also use the old-fashioned function() syntax. If your users are on Internet Explorer, you probably will have to, or you must use a compiler like babel. We use MobX 5, which will not work in modern browsers, MobX 4 should however.

We use MobX version 5 and MobX state tree version 3.8.0. I have once experienced some strange errors when combining versions of MobX and MobX state tree that were not compatible. However, once you make sure the common features work (such as in these examples), you are probably good to go.

Asynchronous actions

The examples above didn’t cover asynchronous actions. Because of the ‘protected’ nature of data in models, it is not possible to do this:

// we have a model and add some actions
.actions(self => ({
   editProperty() {
      waitForSomething(result) => {
         self.someProperty = result; // ERROR!
      });
   }
}));

Instead, you will need to call on another action to actually edit the data:

// we have a model and add some actions
.actions(self => ({
   waitAndEditProperty() {
      waitForSomething(result) => {
         self.editProperty(result);
      });
   },
   editProperty(result) {
      self.someProperty = result;
   }
}));

According to the official documentation, it is recommended to use generators, but since this is a relative new feature in javascript we use the above approach.

Explicit one-way binding

Because it is not possible to edit properties directly, you might run into some errors when binding MobX state tree properties to UI5 controls.

When using controls which have two-way binding by default, you might need to set it to explicit one-way binding like so:

<Input value="{path: '/currentStep', mode: 'OneWay'}" change="onChange"></Input>

Demo

For a presentation at UI5CON, we made some example-projects using MobX and MobX state tree.

Demo3 is the one using MobX state tree. It also has a Book-model and an BookStore so it is pretty similar to the above examples.

Demo4 is an example where we use snapshots to create a ‘time travel’ like functionality, in order to demonstrate some of the things you can make.

Conclusion

I hope this article made you interested in using MobX state tree in a UI5 app. In our team, it is hard to image that we once did not use MobX state tree and I honestly would not know how we would have built the app in its current form without it.

Feel free to ask questions in the comments!

Be the first to leave a comment
You must be Logged on to comment or reply to a post.