Why Mobx Is Not Fashionable Now?

333 阅读4分钟

Mobx was a popular state management library for React that allows you to create observable and reactive data models. However, with the introduction of React Hooks, Mobx becomes less appealing and sometimes conflicts with React's philosophy. In this article, I will explain some of the reasons why Mobx is not fashionable with React Hooks, and suggest some alternatives that might suit your needs better.

The observer trap

One of the core concepts of Mobx is that you need to wrap your components with observer in order to make them react to changes in observable data. This sounds simple enough, but it comes with several drawbacks:

It is very easy to forget adding it. You may start with a pure component without using any reactive object. But later the component may change and it is too easy to miss the observer. If you forget to wrap your component with observer, it will not update when the data changes, and you might spend hours debugging why your component is not working as expected.

You avoid this pitfall by using eslint-plugin-mobx. But it does not make your life better, because:

Wrapped component will break definition lookup in editor. If you use an editor that supports code navigation and definition lookup, such as VS Code, you might find that "go to the definition" of observer-wrapped component will give you two locations. One for the actual component definition and the other observer call.

image.png

image.png This can be confusing and annoying, especially if you have many components in your project. Even inlining component function expression inside observer will not change this behavior. Plus it will conflict with prefer-arrow-callback rule, which recommends using arrow functions for callbacks.

observer must be the innermost (first applied) higher-order-component when it is combined with other decorators or HOC. Otherwise it might do nothing at all. What's more frustrating is that this rule is not easy to be checked with eslint.

As you can see, using observer can introduce a lot of complexity and potential bugs in your code.

The global store dilemma

Another confusion is using global Mobx store. A global store is a singleton object that contains all your application state and logic, and can be imported from any file in your project. You can create a global store using Mobx by using the observable function or the @observable decorator.

However, using a global store also has its drawbacks:

globalStore.reactiveProperty in useEffect will be treated as unnecessary dependencies by the eslint rule react-hooks/exhaustive-deps. If you use a global store property in a useEffect hook, the linter will complain that it is an unnecessary dependency and suggest you to remove it from the dependency array.

But without writing this dependency, useEffect will not be re-run when the store is updated. Why? Because the reactive property must be accessed so that MobX knows the render function or effect function should be re-triggered when the proerty changes. Skipping it will make both Mobx and React ignore the property.

A work around is to use a global store with context API. You can create a context provider that wraps your app component and passes the global store as its value. Then, you can use the useContext hook in any component that needs to access the global store. This way, you can suppress exhaustive-deps' complaints. But, you still need speciy useEffect's dependency array.

You still need annotate useEffect's dependencies

This might seem redundant, as Mobx is already a reactive library, but it is necessary for Mobx to track the changes in the render function.

Will autorun, the utility function from Mobx that re-runs a function whenever an observable value changes, save us? Sadly the answer is no.

autorun run multiple times or will capture stale closure. Let's see the example

class Store {
  num = 123
  constructor() { makeAutoObservable(this) }
}
const store = new Store()

function Component({num}) {
  useEffect(() => {
    console.log('plain effect', store.num, num)
  }, [num])
  useEffect(() => autorun(() => {
    console.log('autorun', store.num, num)
  }), [num])
  return <button onClick={() => store.num++}>click</button>
}
const Comp = observer(Component)
const Root = observer(() => <Comp num={store.num} />)
render(<Root/>, document.getElementById("root"));

In this example, we have a simple counter component that uses a global store created with Mobx. The store has an observable property num. The component uses two useEffect hooks to log the value of store.num and the value of num passed as prop to the console. One effect will run autorun and the other will not.

What's the output? autorun will fire twice, while plain effect will only run once.

image.png

The reason for these problems is that Mobx's autorun function does not work well with React's useEffect hook. The useEffect hook runs only once or when its dependencies change, but the autorun function runs whenever an observable value changes. This means that the autorun function might run before or after the useEffect hook, or it might run multiple times within the same render cycle. This can cause inconsistencies and bugs in your code.

What if we set the dependency array to empty? autorun will always capture the stale closure and num will always be the initial value. So it is not correct either.

Other Pitfalls

Besides the issues mentioned above, there are some other footguns that you might encounter when using Mobx with React Hooks.

These footguns are hidden in the folded tips section of the Mobx documentation, and they might surprise you if you are not aware of them. Here are some examples:

  • Callback components require observer. If your component renders another component vai a callback prop, such as a render prop, you need to wrap the callback component with <Observer> as well. Note, <Observer> and observer are two different concepts. <Observer> is a React component of which the children are React elements in the anonymous region in your component. While observer is a higher order function that takes a component function.
  • SSR memory leak if you forget to call enableStaticRendering(true).
  • Using third-party component library needs extra attention. If you use a third-party component library that is not compatible with Mobx, such as Material UI or Ant Design, you might encounter some problems when using their components with observable data. For example, passing a reactive object as prop may not trigger 3rd-party components' update.

Alternatives?

As you can see, using Mobx with React Hooks can be tricky and error-prone. You need to be aware of the pitfalls and follow the best practices to avoid them.

For simple cases, plain-ol' React context may be sufficient for your work. For more complex scenarios, using more modern state management like jotai and zustand may be better.

If you are really into reactive programming, Vue is a pretty solid choice :).