Application State Management with MobX
Hilla uses a reactive programming model for views and components. In a reactive UI programming model, the view has a declarative template that takes in a state and produces the HTML that should be rendered. In programming terms, you can think of the process as:
renderedHtml = template(state)
That is: the view is a function of the state. Any time the state changes, the rendered HTML is updated.
This chapter covers:
-
application state management with MobX,
-
creating MobX stores,
-
fetching endpoint data and saving it in a store,
-
using application state in a template.
Component vs. Application State
Hilla applications can store state in two places: component properties (@property
or @state
) or MobX stores.
Use MobX stores for application state and state that’s derived from the application state.
Component properties are appropriate for storing state that’s only relevant to a single component and doesn’t depend on the application state.
Introduction to State Management with MobX
MobX is a simple and scalable state management library that’s used widely in the Lit and React communities.
What makes MobX stand out among other state management libraries, such as Redux, is that it requires almost no boilerplate and it uses normal TypeScript syntax to update state. This makes it easy to learn and use.
At the core of MobX is the observable state, the state that gets updated through actions (methods) that get triggered by events.
When the state changes, computed values are updated and, lastly, any side effects are run.
The most common side effects in a Hilla application are rendering the view template and using autorun
functions.
MobX uses stores to store state and logic. A store is similar to a controller in the MVC pattern, or a presenter in an MVP pattern. Stores can be composed of other stores and can be extended dynamically.
Try to keep the state as small as possible and avoid duplication.
Use computed properties to derive values from the existing state.
For instance, don’t store a separate contactCount
value, but compute it instead, based on the contacts array.
MobX caches computed values and only evaluates them if they are used, and if any observables they depend on have changed since they were last computed.
MobX recommends using at least two stores: one for domain objects and one for the application state.
It’s best to keep business and application state separate for easier testing and reuse.
These two stores are managed by a central store called the AppStore
.
Creating the CRM Application State Stores
The Hilla CRM application has three main stores:
-
AppStore
- the container for other stores.-
UiStore
- application state (logged in, offline). -
CrmStore
- domain objects and logic (contacts, companies, states).
-
For now, you only need the AppStore
and CrmStore
.
You add the UiStore
later, in the Adding a Login Screen and Authenticating Users chapter.
Begin by creating the CrmStore
to manage domain objects.
Create a new file, frontend/stores/crm-store.ts
, with the following content.
import { makeAutoObservable, observable, runInAction } from 'mobx';
import Company from 'Frontend/generated/com/example/application/data/entity/Company';
import Contact from 'Frontend/generated/com/example/application/data/entity/Contact';
import Status from 'Frontend/generated/com/example/application/data/entity/Status';
import { CrmEndpoint } from 'Frontend/generated/endpoints';
export class CrmStore {
contacts: Contact[] = [];
companies: Company[] = [];
statuses: Status[] = [];
}
Notice that import
generated TypeScript types for Contact
, Company
, and Status
.
Also import the CrmEndpoint
.
The state consists of three arrays: contacts
, companies
, and statuses
.
Turn the class into a MobX observable (store) by calling makeAutoObservable()
in the constructor:
constructor() {
makeAutoObservable(
this,
{
initFromServer: false,
contacts: observable.shallow,
companies: observable.shallow,
statuses: observable.shallow,
},
{ autoBind: true }
);
this.initFromServer();
}
makeAutoObservable()
takes three arguments:
-
The observable class.
-
Overrides. By default, all fields are made into observed values, all functions into actions, and all getters into computed values. In this case, you only want to observe changes to the array content, not the content of the objects in the array. This way, MobX doesn’t create proxy objects for all objects. Also, you don’t need the
initFromServer()
method to be turned into an action. -
Options. Enable
autoBind
, which will bind all actions to this class. This makes it easier to use them in listeners later on.
Add a method that initializes the store from the server.
async initFromServer() {
const data = await CrmEndpoint.getCrmData();
runInAction(() => {
this.contacts = data.contacts;
this.companies = data.companies;
this.statuses = data.statuses;
});
}
initFromServer()
is an async
method.
async
methods can use the await
keyword to suspend execution until a Promise
resolves.
async
methods make it easier to write non-blocking asynchronous code.
Observables need to be updated through actions.
Normally, all methods on the store are actions.
But asynchronous code needs to be handled slightly differently.
Because the await
keyword causes execution to suspend, the original action is no longer active when the value is returned.
You can work around this by either having a separate method just to set the values, or by using runInAction()
to explicitly run the state update in an action.
Lastly, replace the contents of frontend/stores/app-store.ts
with the following:
import { CrmStore } from "./crm-store";
export class AppStore {
crmStore = new CrmStore();
}
export const appStore = new AppStore();
export const crmStore = appStore.crmStore;
The purpose of the AppStore
is to ensure that you have only one instance of the stores and that they are in sync.
Export the crmStore
member for convenience.
This way, you can import and use crmStore
instead of appStore.crmStore
, while still ensuring that you work with only one set of stores.
Using a MobX Store from a View Template
Now that you have a store that contains the state, you can use it to display contacts in the list view grid.
First, import the store into the list view:
import { crmStore } from 'Frontend/stores/app-store';
Next, update the template.
Use a property binding on vaadin-grid
to bind the contacts state to the items
property.
<vaadin-grid class="grid h-full" .items=${crmStore.contacts}>
In your browser, you should now see all the contacts listed in the grid.
If you don’t have the development server running, start it with the mvn
command from the command line.
