Avatar

How I migrated from Redux and Saga to MobX and became a happier person

← Back to list
Posted on 15.07.2020
Last updated on 04.12.2024
Image by Kin Li on Unsplash
Refill!

Table of contents

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 code is licensed under the MIT license

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);
The code is licensed under the MIT license

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>
);
The code is licensed under the MIT license

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>
);
};
The code is licensed under the MIT license

# 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>);
The code is licensed under the MIT license

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;
}
}
The code is licensed under the MIT license

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>
);
};
The code is licensed under the MIT license

# 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:

  1. A user opens a page.
  2. The page data starts to load, the user is looking at the loading indicator meanwhile.
  3. The user leaves the page.
  4. It takes some time to load the data due to poor network connection.
  5. The data arrives, the state gets updated even if the displayed page is not the same the data was obtained for.
  6. ???

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>
);
};
The code is licensed under the MIT license

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;
}
}
The code is licensed under the MIT license

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;
}
}
The code is licensed under the MIT license

# 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 and Action entities are now united into one single entity called State. 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 from React. It means that one day MobX may become ideologically incompatible with potentially upcoming React 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 is plenty alternatives out there. Just check them out.

I hope it was helpful. So, what do you think of MobX? Text me a message to discuss!


Avatar

Sergei Gannochenko

Business-oriented fullstack engineer, in ❤️ with Tech.
Golang, React, TypeScript, Docker, AWS, Jamstack.
19+ years in dev.