For quite a while I was using Redux
together with Redux Saga
and was satisfied. Well, sort of, because I was constantly asking myself "Should the state management always be that complex?"
Redux
was a great library for it's time, when the concept of a centralized state was not well-coined yet. Nevertheless, one day came, and I have decided it was time to move.
Requirements
In my applications I had:
- one special reducer that keeps data of the currently authenticated user, feature flags and some other different stuff,
- one reducer per page, combined all together with
combineReducers
, - one saga per page,
- a way to observe and affect the state outside of the rendering context,
- a way to keep routing history and state in sync.
So will see how I'll tackle all of this with MobX
. But first, a short intro to MobX
is required.
The State
According to MobX
, a state is the heart of an application.
As a substitution for reducers I have decided to make several sub-states. So, there is a "root" state called State
:
import { observable, computed, configure } from 'mobx';
import { Nullable } from '../../type';
// pre-configure MobX to have better experience
// see https://mobx.js.org/refguide/api.html
configure({ enforceActions: 'observed', computedRequiresReaction: true });
export class State {
@observable public loading = false;
@observable public error: Nullable<Error> = null;
@computed get ready(): boolean {
return this.loading || !this.error;
}
}
The MobX
state is not just a plain object. The state object has a prototype, which automatically enables the ultimate fun with all that OOP stuff: getters / setters, methods, incapsulation and so on. I am not a huge fan of OOP though, but occasionally it may be helpful.
You may have already noticed a couple of decorators back there.
-
@observable decorator tells
MobX
to observe this particular property for changes, - @computed decorator tells that there is a getter returning a value that is dependent on some observed properties and does not have side effects. This enables memoization.
Consuming the state
First thing first, I need to inject my state into components. React context and hooks are to my rescue here!
import React, { useContext } from 'react';
import { State } from './state';
import { Nullable } from '../../type';
type NullableState = Nullable<State>;
export type StatePropsType = {
state: State;
};
export const StateContext = React.createContext<NullableState>(null);
export const StateProvider = StateContext.Provider;
export const useGlobalState = () => useContext<NullableState>(StateContext);
Then I modify my Providers
component like this:
import React, { FunctionComponent } from 'react';
import { State } from '../../state/state';
import { StateProvider } from '../../state/context';
const state = new State();
export const Providers: FunctionComponent = ({ children }) => (
<StateProvider value={state}>
{children}
</StateProvider>
);
And finally, everywhere I want this state consumed I just use useGlobalState
hook:
import React, { useEffect, FunctionComponent } from 'react';
import { ApplicationProps } from './type';
import { useGlobalState } from '../state/context';
export const Application: FunctionComponent<ApplicationProps> = () => {
const state = useGlobalState()!;
return (
<div>
MobX is cool!
<Loader state={state} />
</div>
);
};
Reacting to state changes
Having the state consumed does not automatically mean the component will react on it's changes. However, just like Redux has a connector module react-redux
, there is a module called mobx-react
.
import { observer } from 'mobx-react';
const Loader = observer(({ state }: StatePropsType) => <span>{state.ready ? '' : 'Loading...'}</span>);
A few important things here:
-
observer is a wrapper function that makes our component "sensitive" to state changes. This is an analog of
connect()
from 'react-redux'. - There is also an @observer decorator, but it works only with
class components
(which I use less and less frequently as time goes by). - I shall keep my
observers
as tiny as possible, to avoid re-rendering of different neighboring chunks of UI that do not care about the state at all.
Changing the state
There are different ways to get the state changed. Personally, I prefer doing it Redux-way: via actions. But actions in MobX
is nothing like in Redux
: I don't need to dispatch anything anywhere hopefully :) An action here is just a method of the State
class.
Let's modify the State
class by adding an action or two!
import { observable, computed, configure, action } from 'mobx';
import { Nullable } from '../../type';
// pre-configure MobX to have better experience
// see https://mobx.js.org/refguide/api.html
configure({ enforceActions: 'observed', computedRequiresReaction: true });
export class State {
@observable public loading = false;
@observable public error: Nullable<Error> = null;
@computed get ready(): boolean {
return this.loading || !this.error;
}
@action.bound
public start() {
this.error = null;
this.loading = true;
}
@action.bound
public stop(error?: Error) {
if (error) {
this.error = Error;
}
this.loading = false;
}
}
Here we have an @action.bound decorator (bound means that I can safely use this
inside).
Then nothing stops me from calling these two beautiful actions to change the state:
import React, { useEffect, FunctionComponent } from 'react';
import { ApplicationProps } from './type';
import { useGlobalState } from '../state/context';
export const Application: FunctionComponent<ApplicationProps> = () => {
const state = useGlobalState()!;
return (
<div>
MobX is cool!<br />
<button onClick={() => state.start()}>Start</button>{' '}<button onClick={() => state.stop()}>Start</button><br />
<Loader state={state} />
</div>
);
};
Asynchronous actions
While @action.bound
works nicely for sync actions, for the async ones things turn a bit more complicated.
It might seem so at the beginning, that I just need to add async / await
to an action, and that will be enough. Well, yeh, technically it solves the case, but what if I need for something more reliable?
The problem with async / await
is that I don't really have any control over the flow, and if an action takes longer than anticipated initially to execute, I might get into race condition troubles there.
So consider the following situation:
- A user opens a page.
- The data starts to load, the user is looking at the loading indicator meanwhile.
- The user leaves the page.
- It takes some time to load the data due to poor network connection.
- The data arrives, the state gets updated even if the displayed page is not the same the data was obtained for.
- ???
Is there a way to tell our async action, that we are leaving the page and no longer interested in the data?
Yes.
This is where generators
come out. The coolest thing about a generator yielding promises is that it forms an async process which can be interrupted in the middle, right between two yield
operators. From the developers perspective, a generator looks like just a normal function.
So this is how I have implemented this.
Sub-states
I have decided to make a sub-state for each page of my application, just like I used to have separate reducers in Redux
before. That is my home page for instance:
import React, { FunctionComponent, useEffect } from 'react';
import { Layout } from '../../components';
import { useGlobalState } from '../../state/context';
export const HomePage: FunctionComponent = () => {
const state = useGlobalState()!;
const { homePage } = state;
useEffect(() => {
homePage.onLoad();
return () => homePage.onUnload();
}, [homePage]);
return (
<Layout>
Honey, I am home!
</Layout>
);
};
When I enter or leave the page, the onLoad()
or onUnload()
is called respectively.
And now the sub-state itself:
import { action, observable, flow, isFlowCancellationError } from 'mobx';
import { CancellablePromise } from 'mobx/lib/api/flow';
import { Nullable, ObjectLiteral } from '../../../type';
export class HomePageState {
@observable ready = false;
@observable loading = false;
@observable error: Nullable<Error> = null;
private queryLoad: Nullable<CancellablePromise<unknown>> = null;
onLoad() {
if (this.queryLoad) {
// something is already being loaded for this page. Non-relevant, abort
this.queryLoad.cancel();
}
// start new query
this.queryLoad = this.startLoading();
this.queryLoad.catch((error) => {
if (!isFlowCancellationError(error)) {
// the query was not cancelled, some other error popped up
console.error(error);
}
});
}
onUnload() {
if (this.queryLoad) {
// we are leaving, stop whatever you are doing now
this.queryLoad.cancel();
this.queryLoad = null;
}
this.reset();
}
@action.bound
reset(): void {
this.ready = false;
this.loading = false;
this.error = null;
}
// that is our async process
startLoading = flow(function* startLoading() {
// @ts-ignore who cares?:)
const self = this as HomePageState;
self.loading = true;
self.error = null;
self.ready = false;
// load something really heavy
yield new Promise((resolve) => {
setTimeout(resolve, 5000);
});
yield self.finishLoading();
});
@action.bound
finishLoading(error?: Error): void {
this.loading = false;
if (error) {
this.error = error;
}
this.ready = true;
}
}
Okay, so flow is the cake here. Since it is a generator, it can be cancelled between yields. So cool.
Flow here acts as a direct analog of Redux Saga
.
I inject a sub-state into the main State
like this:
import { observable, computed, configure, action } from 'mobx';
import { Nullable } from '../../type';
import { HomePageState } from '../../pages/HomePage';
// pre-configure MobX to have better experience
// see https://mobx.js.org/refguide/api.html
configure({ enforceActions: 'observed', computedRequiresReaction: true });
export class State {
@observable public loading = false;
@observable public error: Nullable<Error> = null;
public homePage = new HomePageState();
@computed get ready(): boolean {
return this.loading || !this.error;
}
@action.bound
public start() {
this.error = null;
this.loading = true;
}
@action.bound
public stop(error?: Error) {
if (error) {
this.error = Error;
}
this.loading = false;
}
}
Conclusion
That is basically it! For me the codebase shrank significantly. All the cryptic reducers and the "plain object" state concept is now gone. This all brings me to the following outcome:
Pros of MobX
-
MobX
is (almost) completely decoupled from the rendering cycle. For me it is a plus, since personally I don't like the do-everything-in-the-component approach. IMO, React should only know how to render the UI, and everything else should be delegated to other parties. Unfortunately, this can also manifest itself as a drawback (see below). -
State
,Reducer
andAction
entities are now united into one single entity calledState
. It just feels more natural. I can also treat the entire thing as a blackbox when needed. - All the complexity introduced by
Redux
is no longer on the picture (Phewww...)
Cons of MobX
- Decorators work only with class components. If you need to turn a function component into an observer, you need to use alternative
observer()
syntax, which does not please the eye at all. -
MobX
is (almost) completely decoupled fromReact
. It means that one dayMobX
may become ideologically incompatible with potentially upcomingReact
features. - The state changes are not discrete, and this fact makes the system more difficult to debug.
- Therefore, No time travel debugging and undo ability
I have prepared a proof of concept repo to show the thing in action :)
Alternatives
If MobX
does not suit you well, there are many-many alternatives out there. Just check them out.
- Apollo link state
- Overmind
- Recoil by Facebook
- Context API
- Unstated-next
- ... and many others :)
I hope it was helpful. So, what do you think of MobX? Text me a message to discuss!
- Header photo by Kin Li on Unsplash
Top comments (0)