Introduction to State Management
- What is the client state?
- The side effect model
- A speed tour of MobX
The client state
In short, it is the data that takes on a pivotal role in describing the UI. Handling the structure and managing the changes that can happend to this data is what we commonly refer to as state management. State is just a synonym for the client-data that is rendered on the UI.
UI = fn(State);
VirtualDOM = fn(props, state);
复制代码
Handling changes in state
After all, the interface is not just used to visually represent data (state), but to also allow the manipulation of that data. This is where we need to introduce the concept of actions that represent these user operations, which results in a change in state.
State => notifies => UI => fires => Action => changes => State;
复制代码
The side effect model
Side effects are a result of some state-change and are invoked by responding to the notifications coming from the state. Just like the UI, there is a handler, which we can call the side effect handler, that observes (subscribes to) the state change notification. When a matching state change happens, the corresponding side effect is invoked.
State => (notifies) => Side Effect Handlers => (products) => Side Effects
[ => (actions) => State ]
复制代码
A speed tour of MobX
Observable represents the reactive state of your application. Any JavaScript object can be used to create an observable.
import { observable } from "mobx";
let cart = observable({
itemCount: 0,
modified: new Date()
});
复制代码
Observables alone cannot make an interesting system. We also need their couterparts, the Observers. MobX gives you three different kinds of observes: autorun, reaction, when.
import { observable, autorun, action } from 'mobx';
let cart = observable({
itemCount: 0,
modified: new Date()
});
autorun(() => {
console.log(`The Cart contains ${cart.itemCount} item(s).`);
});
const incrementCount = action(() => {
cart.itemCount++;
});
incrementCount();
// Console output:
The Cart contains 0 item(s).
The Cart contains 1 item(s).
复制代码
Remember that the state should not be changed directly and instead should be done via actions. When you are modifying a lot of observables inside your action, you don't want to be notified about every little change immediately. Instead, you want to be able to wait for all changes to complete and then fire the notifications.
observables => observers (UI && Side Effects ) => actions => observables
复制代码
Here is the top-level scoop:
- Define the observable state for the feature in a store class. The various properties that can be changed and should be observed are marked with the observable API.
- Define actions that will be needed to mutate the observable state.
- Define all of the side effects (autorun, reaction and when) within the same feature class. The co-location of actions, reactions, and the observable state keeps the mental model crisp. MobX also supports async state updates out of the box, so no additional middleware libraries are needed to manage it.
- Use the mobx-react package that includes the observer API, which allows the React components to connect to the observable store. You can sprinkle observer components throughout your React component tree, which is in fact the recommended approach to fine-tune component updates.
- The advantage of using the observer is that there is no extra work needed to make the component efficient. Internally, the observer API ensures that the component is only updated when the rendered observable state changes.
Observables, Actions, and Reactions
- Creating the various kinds of observables
- Setting up the actions that mutate the observables
- Using reactions to handle external changes
objects => observable.object({})
arrays => observable.array([])
maps => observable.map(value)
primitives(number, string, boolean, null, undefined), functions, class-instance => observable.box(value)
复制代码
By passing in { deep: false } as an option, you can effectively prune the observability just to the first level.
We can define a computed description property by simply adding a get-property to the observable. It also caches the value of the computed property to avoid unnecessary computation.
The decorator syntax is only avaialbe for classes and can be used for class declarations, properties and methods.
class Cart {
@observable.shallow items = []; // { deep: false }
@observable modified = new Date();
@computed get description() {
switch (this.items.length) {
case 0:
return "There are no items in the Cart";
case 1:
return "There is one item in the Cart";
default:
return `There are ${this.items.length} items in the Cart`;
}
}
}
复制代码
The configure() function can be used to set the enforceActions option to true. MobX will now throw an error if you try to modify an observable outside of an action.
import { observable, configure } from "mobx";
configure({
enforceActions: true // 'strict' => will throw an error even if there are no observers attached to the mutating observables
});
复制代码
autorun() is a long-running side-effect that takes in a function (effect-function) as its argument.
const cancelAutorun = autorun(() => {
console.log(`Items in Cart: ${this.items.length}`);
});
cancelAutorun();
复制代码
reaction() is similar to autorun() but waits for a change in the observables before executing the effect-function. reaction() in fact takes two arguments.
reaction(trackerFunction, effectFunction): disposerFunction
trackerFunction: () => data, effectFunction: data => {}
复制代码
tracker-function is where all the observables are tracked. Any time the tracked observables change, it will re-execute. It is supposed to return a value that is used to compare it to the previous run of tracker-function. If these return-values differ, the effect-function is executed.
when() only executes the effect-function when a condition is met and automatically dispose the side-effect after that.
when(predicateFunction, effectFunction): disposerFunction
predicateFunction: () => boolean, effectFunction: () => {}
/** @desc when() with a promise */
class Inventory {
/* ... */
async trackAvailability(name) {
// 1. Wait for availability
await when(() => {
const item = this.items.find(x => x.name === name);
return item ? item.quantity > 0 : false;
});
// 2. Execute side-effect
console.log(`${name} is now available`);
} /* ... */
}
复制代码
- autorun( effect-function: () => {} ): Useful for long-running side-effects. The effect function executes immediately and also anytime the dependent observables (used within it) change. It returns a disposer function that can be used to cancel anytime.
- reaction( tracker-function: () => data, effect-function: (data) => {} ): Also for long-running side-effects. It executes the effect function only when the data returned by the tracker function is different. In other words, reaction() waits for a change in the observables before any side-effects are run. It also gives back a disposer function to cancel the effect prematurely.
- when( predicate-function: () => boolean, effect-function: () => {} ): Useful for one-off effects. The predicate function is evaluated anytime its dependent observables change. It executes the effect function only when the predicate function returns true. when() automatically disposes itself after running the effect function. There is a special form of when() that only takes in the predicate function and returns a promise. Use it with async-await for a simpler when().
A React App with MobX
- The book search use-case
- Creating the observable state and actions
- Building the Reactive UI
There is some state to capture the async search() operation that we will be invoking. The initial status of the operation is empty. Once the user invokes the search, we are in the pending state. When the search completes, we could either be in the completed or failed state.
class BookSearchStore {
@observable term: '',
@observable state: '',
@observable results: [],
@observable totalCount: 0,
@action.bound search(value) {
// invoke search API
},
@action.bound setTerm(value) { // By passing a plain-function into the action(), we can be assured that this would point to the correct instance of the obaservable.
this.term = value;
}
};
export const store = new BookSearchStore();
复制代码
@action.bound
async search() {
try {
this.status = 'pending';
const result = await searchBooks(this.term);
runInAction(() => {
this.totalCount = result.total;
this.results = result.items;
this.status = 'completed';
})
} catch (err) {
runInAction(() => { this.status = 'failed' });
console.log(err);
}
}
复制代码
import { store } from './BookStore';
import { preferences } from 'PreferencesStore';
import { Provider } from 'mobx-react';
ReactDOM.render(
<Provider store={store} userPreferences={preferences}>
<App />
</Provider>,
document.getElementById('root')
);
import { inject, observer } from 'mobx-react';
@inject('store')
@observer
class App extends React.Component {
render() {
const { store } = this.props;
return (/* ... */)
}
}
复制代码
import React, { Fragment } from 'react';
import { inject, observer } from 'mobx-react';
export const SearchStatus = inject('store')(
observer(({ store }) => {
const { status, term } = store;
return (
<Fragment>
{ status === 'pending' ?
( <LinearProgress variant={'query'} /> )
: null}
{ status === 'failed' ?
(
<Typography variant={'subheading'} style={{ color: 'red', marginTop: '1rem' }}>
{`Failed to fetch results for "${term}"`}
</Typography>
)
: null}
</Fragment>
);
}));
复制代码
rafting the Observable Tree
-
The shape of data
-
Controlling observability with various decorators
-
Creating computed properties
-
Modeling MobX stores with classes
-
Observables, which represent the application state
-
Actions, which mutate it
-
Reactions, which produce side effects by observing the changing observables
Creating reference-only observables with @observable.ref.
Every time a new object is assigned to the observable, it will be considered as a change, and reactions will fire. What you really need is a structural check where the properties of your object are compared instead of the object reference, that is the purpose of @observalbe.struct. It does a deep comparison based on property values rather then relying on the top-level reference.
Using the decorate() API, you can selectively target properties and specify the observability.
import { action, computed, decorate, observable } from 'mobx';
class BookSearchStore {
term = 'javascript';
status = '';
results = [];
totalCount = 0;
get isEmpty() {
return this.results.length === 0;
}
setTerm(value) {
this.term = value;
}
async serach() {}
}
// decorate(target, decorator-object)
decorate(BookSearchStore, {
term: observable,
status: observable,
results: observable.shallow,
totalCount: observable,
isEmpty: computed,
setTerm: action.bound,
search: action.bound
})
复制代码
Decorating with observable(): observable(properties, decorators, options) options: { deep: false|true, name: string }
There will be times where you need runtime abilities to extend the observability. This is where the extendObservable() API comes in. It allows you to mix in additional properties at runtime and make them observable as well.
import { observable, action, extendObservable } from 'mobx';
const cart = observable({
/* ... */
});
function applyFestiveOffer(cart) {
extendObservable(
cart,
{
coupons: ['OFF50FORU'],
get hasCoupons() {
return this.coupons && this.coupons.length > 0;
},
addCoupon(coupon) {
this.coupons.push(coupon);
},
},
{
coupons: observable.shallow,
addCoupon: action,
},
);
}
// extendObservable(target, object, decorators)
// observable() => extendObservable({}, object)
复制代码
@computed.struct does a deep comparison of the object structure. This ensures that no notifications are fired when a re-evaluation of the metrics property gives back the same structure.
The first step in modeling with MobX is to identify the observable state and actions that can mutate it.
import { observable } from 'mobx';
class WishListStore {
@observable.shallow lists = [];
@computed
get isEmpty() {
return this.lists.length === 0;
}
@action
addWishList(name) {
this.lists.push(new WishList(name));
}
}
class WishList {
@observable name = '';
@observable.shallow items = [];
@computed
get isEmpty() {
return this.items.length === 0;
}
@action
renameWishList(newName) {
this.name = newName;
}
@action
addItem(title) {
this.items.push(new WishListItem(title));
}
@removeItem(title) {
this.items.remove(item);
}
constructor(name) {
this.name = name;
}
}
class WishListItem {
@observable title = '';
@observable purchased = false;
constructor(title) {
this.title = title;
}
}
const store = new WishListStore();
复制代码