How I migrated from Redux and Saga to MobX and became a happier person
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.
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.
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.htmlconfigure({ 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.
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>);};
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.
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.htmlconfigure({ 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.boundpublic start() {this.error = null;this.loading = true;}@action.boundpublic 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>);};
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 page 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.
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, abortthis.queryLoad.cancel();}// start new querythis.queryLoad = this.startLoading();this.queryLoad.catch((error) => {if (!isFlowCancellationError(error)) {// the query was not cancelled, some other error popped upconsole.error(error);}});}onUnload() {if (this.queryLoad) {// we are leaving, stop whatever you are doing nowthis.queryLoad.cancel();this.queryLoad = null;}this.reset();}@action.boundreset(): void {this.ready = false;this.loading = false;this.error = null;}// that is our async processstartLoading = flow(function* startLoading() {// @ts-ignore who cares?:)const self = this as HomePageState;self.loading = true;self.error = null;self.ready = false;// load something really heavyyield new Promise((resolve) => {setTimeout(resolve, 5000);});yield self.finishLoading();});@action.boundfinishLoading(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.htmlconfigure({ 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.boundpublic start() {this.error = null;this.loading = true;}@action.boundpublic stop(error?: Error) {if (error) {this.error = Error;}this.loading = false;}}
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:
- 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...)
- 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 :)
If MobX does not suit you well, there is plenty 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!
Sergei Gannochenko
Golang, React, TypeScript, Docker, AWS, Jamstack.
19+ years in dev.