The profile
This article focuses on the bug fixes that I encountered in my development work and tried to solve with Mobx. In my opinion, the cooperation of the two can greatly reduce the problems that may occur in the development process and greatly improve the development experience, and the learning cost is also very low. If you are interested in Hooks and Mobx and want to learn more, then this article is for you. This article introduces the following to help you decide if you want to read it carefully and save time:
- This article won’t get too basic, you need a basic understanding of Mobx and Hooks
- This article introduces some of the best practices in daily development, for the convenience of friends to have a deeper understanding of both
- If you’ve used part of Mobx, but don’t know how to work better with Hooks, try it
Hooks are really simple to understand, so this article isn’t long, I don’t like to write a 10,000-word article, not a tutorial, and the reader loses interest by looking at the title.
What exactly is wrong with Hooks?
First of all, I’m not going to talk about Hooks’ merits here, because they are clear to anyone who has used them, so I’m going to focus on Hooks’ shortcomings and how to improve them with Mobx.
- Dependency infectivity — this leads to increased development complexity and decreased maintainability
- Cache avalanche – This leads to performance degradation
- Batch updates are not available under asynchronous tasks – this can also lead to performance degradation
In other words, this is mainly because Hooks create a new closure each time, and all the variables inside the closure are actually new. Closure data is created each time, and caching is necessary from a performance perspective. And caching brings up a lot of problems.
After all, this means that there is no common space to share data, which in the Class component is this, and in Vue3, which is the Setup scope. In Hooks, there is no way to find the shared scope unless you want to write useRef + ref.current.
The combination of Mobx and Hooks provides a unified scope under Hooks to solve the above problems.
Hook1 useObserver
In traditional Mobx usage, you probably know the Observer API as a wrapper around components that need to be responsive. Also, this API works correctly directly in hooks. But Hooks doesn’t recommend hoc. Naturally, Mobx also provides a way to use Hookify: useObserver.
const store = observable({})
function App() {
return useObserver(() => {
return <div>{store.count}</div>
})
}
Copy the code
If you’ve used Mobx before, you can see that useObserver is used almost exactly the same way the Render function of the Class component is used. This is true, and the rule is simple: simply wrap the Node that needs to be returned with this hooks and return it.
After this process, the component can successfully listen for changes in the data, and when the data changes, the component will be rerendered. That’s it for the first API
Hook2 useLocalStore
Simply a more convenient Observable wrapped in Hooks. You give it a function that returns an object that needs a response. It’s a simple way to think about it
const store = useLocalStore((a)= > ({key: 'value'}))
// equal
const [store] = useState((a)= > obserable({key: 'value'}))
Copy the code
And then there is no, extremely simple API to use. Some of the best practices that will be discussed later are also based on this. The following article simplifies the use of local Store reference.
What do these two apis bring?
In short, solve the problem caused by Hooks without retaining their features.
First, due to the existence of the Local Store, which stores data as a constant object, we can ensure that references to the same function at different times remain unchanged, and the same object or data can be referenced at different times. You no longer need to manually add the associated DEPs. This avoids the overuse of useCallback and useRef, as well as the trap of closures that many hooks face (automatically ignored by old hands). The problems of relying on transitivity and caching avalanches can be solved
Go straight to the code, focusing on the comments
// Implement a method that triggers component updates only when the mouse moves more than a few pixels
// props. Size Controls how many pixels to move before triggering a callback
function MouseEventListener(props) {
const [pos, setPos] = useState({x: 0.y: 0})
const posRef = useRef()
const propsRef = useRef()
// We need to use Ref to store the latest value to ensure that the callback is using the latest value
posRef.current = pos
propsRef.current = propsRef
useEffect((a)= > {
const handler = (e) = > {
const newPos = {x: e.xxx, y: e.xxx}
const oldPos = posRef.current
const size = propsRef.current.size
if (
Math.abs(newPos.x - oldPos.x) >= size
|| Math.abs(newPos.y - oldPos.y) >= size
) {
setPos(newPos)
}
}
// Register this event when the component is mounted
document.addEventListener('mousemove', handler)
return (a)= > document.removeEventListener('mousemove', handler)
[pos.x, pos.y]
}, [])
return (
props.children(pos.x, pos.y)
)
}
// Rewritten with Mobx, this approach is far more intuitive than native hooks.
// There will not be any ref, any current usage, any dependency changes
function MouseEventListenerMobx(props) {
const state = useLocalStore(target= > ({
x: 0.y: 0,
handler(e) {
const nx = e.xxx
const ny = e.xxx
if (
Math.abs(nx - state.x) >= target.size ||
Math.abs(ny - state.y) >= target.size
) {
state.x = nx
state.y = ny
}
}
}), props)
useEffect((a)= > {
document.addEventListener('mousemove', state.handler)
return (a)= > document.removeEventListener('mousemove', state.handler)
}, [])
return useObserver((a)= > props.children(state.x, state.y))
}
Copy the code
Secondly, mobx action can solve the problem of batch update of asynchronous data
// After the component is mounted, pull the data and re-render it. Regardless of the error case
function AppWithHooks() {
const [data, setData] = useState({})
const [loading, setLoading] = useState(true)
useEffect(async() = > {const data = await fetchData()
// In an asynchronous callback, a batch update cannot be triggered, so setData updates once and setLoading updates once
setData(data)
setLoading(false)}, [])return (/* ui */)}function AppWithMobx() {
const store = useLocalStore((a)= > ({
data: {},
loading: true,
}))
useEffect(async() = > {const data = await fetchData()
runInAction((a)= > {
// With mobx action, it is possible to batch update components only once
store.data = data
store.loading = false}}), [])return useObserver((a)= > (/* ui */))}Copy the code
However, some people may say that in this case, the use of useReducer is not good? Yes, it is possible for this example, but there are often a lot of complications in the business. For example, if you need to update the local store and the global store in an asynchronous callback, even the useReducer will call the Dispatch twice, which will also trigger two renders. Mobx actions don’t have this problem. // If you force ReactDOM. Unstable_batchedUpdates I won’t say anything
Quick Tips
Now that you know the two apis above, you can start to enjoy using them, but here are a few tips to help you better understand and use them. (I don’t want to use the term “best practices” and I don’t dare to use it. It feels too absolute. Here are some tips that I haven’t polished myself.)
no this
For functions inside a store to get store data, we usually use this to get the store data. Such as
const store = useLocalStore((a)= > ({
count: 0,
add() {
this.add++
}
}))
const { add } = store
add() // boom
Copy the code
In general, there is no problem in using this method. However, this relies on caller and cannot properly use deconstruction syntax. Therefore, it is not recommended to use this, but a criterion of no this. Refers directly to its own variable name
const store = useLocalStore((a)= > ({
count: 0,
add() {
store.count++
}
}))
const { add } = store
add() // correct, will not cause this error
Copy the code
- Avoid confusion when this points to
- Avoid directly deconstructing this when using it and thus losing it
- Avoid using arrow functions to define store actions directly. First, it is not necessary, and second, it is clearer to divide responsibilities. Those are states and those are actions
source
In some cases, the local store might need to get props, and source can easily convert the props to an Observable.
function App(props) {
const store = useLocalStore(source= > ({
doSomething() {
// Source is reactive, and when the external props changes, the target also changes
if (source.count) {}
// If we use props directly, because of the closure nature, the props is not changed at all
// While props is a different object each time, source is the same object reference each time
// if (props.count) {}
}
// This is done with the second argument
}), props)
// return Node
}
Copy the code
This is not only used to transform props, but also to transform a lot of non-Observable data into Observables, such as Context, State, etc
const context = useContext(SomeContext)
const [count, setCount] = useState(0)
const store = useLocalStore(source= > ({
getCount() {
returnsource.count * context.multi } }), {... props, ... context, count})Copy the code
Custom observables
In some cases, the default Observable policy can have some performance issues, such as not wanting to be fully responsive to large objects. You can do this by returning a custom Observable.
const store = useLocalStore((a)= > observable({
hugeObject: {},
hugeArray: []}, {hugeObject: observable.ref,
hugeArray: observable.shallow,
}))
Copy the code
Even if you don’t think customization is enough, you can just return a custom store
const store = useLocalStore((a)= > new ComponentStore())
Copy the code
Type inference
By default, the most convenient and efficient type definition is derived by example, not by generics. This approach balances development efficiency with code readability and maintainability. Of course, you can use generics if you want
In this way, the type is derived directly from the object literal
const store = useLocalStore((a)= > ({
todos: [] as Todo[],
}))
// Of course you can use generic definitions, as long as you don't get annoyed
const store = useLocalStore<{
todos: Todo[]
}>(() = > ({todos: []}))
Copy the code
However, this is only recommended when used as a local store, i.e. the relevant data is used within this component. If you use custom Hooks, it is recommended to use predefined types and then generics to provide more flexibility.
memo?
When using the useObserver API, we lose the ability of the Observer decorator to skip rendering, which is the default support for shallow comparison props, and we need to manually use the Memo to do this optimization ourselves
In addition, the Memo is far better than the Observer because the Memo is not a simple hoc
export default memo(function App(){
const xxx = useLocalStore((a)= > ({}))
return useObserver((a)= > {
return (<div/>)})})Copy the code
No longer recommended useCallback/useRef/useMemo built-in Hooks
All of these Hooks can be replaced by useLocalStore. Built-in Hooks are not necessary for Mobx. In addition, the use of these built-in apis may cause cache problems. Therefore, the following migration is recommended
- UseCallback has two approaches
- If the function does not need to be passed to the child, then there is no need for caching at all. Simply remove the useCallback or put it in the local store
- If a function needs to be passed to a child component, just put it in the Local store.
- UseMemo goes directly to the Local store and is used through the getter
useEffect or reaction?
UseEffect is a feature that can be used as watcher. Mobx also has its own ability to listen for changes: reaction. Which is the better way to use it?
Here is the recommendation, use both, ha ha ha, surprise.
useEffect((a)= >
reaction((a)= > store.count, () => console.log('changed')), [])Copy the code
Seriously, useEffect is used for non-responsive data, while reaction is preferred for responsive data. Of course, if you do away with native hooks all the way, you can just use reaction.
Mix? Break up?
Logical split and composition is a big advantage of Hooks, which can still be maintained with Mobx. It’s even simpler.
function useCustomHooks() {
// It is recommended to use global Store rules to constrain custom Hooks
const store = useLocalStore((a)= > ({
count: 0,
setCount(count) {
store.count = count
}
}))
return store
}
function App() {
// There are two ways to think about the store
// First, it is a local store, which means that each store initializes a new one
// Second, it can be used as a global store localization. that is, you can use it as a global store
const store = useCustomHook()
return (
// ui)}Copy the code
App Store
Mobx itself provides the ability to act as a global Store, just to mention the use posture in conjunction with Hooks
When you upgrade to mobx-react@6, hooks are officially supported, which means you can simply use them this way
export function App() {
return (
<Provider sa={saStore} sb={sbStore}>
<Todo/>
</Provider>)}export function Todo() {
const {sa, sb} = useContext(MobxProviderContext)
return (
<div>{sa.foo} {sb.bar}</div>)}Copy the code
Context is always a data sharing scheme, not a data hosting scheme, i.e. a Store
What does this mean for data sharing and component communication? For example
- There is some basic configuration information that needs to be passed down, such as Theme. Subcomponents usually just need to read and render accordingly. In other words, control of the data resides with the upper components, and it is the upper components that share the data with the lower components. The data flow is usually one-way, or mainly one-way. This is data sharing
- In cases where components need to communicate with each other, such as when component A needs to modify something on component B, it is common practice to store common data one layer up, that is, hosted to the upper layer, but use control resides with the lower component. And that’s really what the Global Store, which is what libraries like Redux do. It can be seen that data flow is often two-way, which can be regarded as data hosting
Having watched the development of Hooks, a lot of people started trying to replace Redux with Context + useReducer when Hooks were born, which I think is a bit of a distortion of Context.
If the Context is a global Store, then it must be mounted on the root component, and Context checks for changes by comparing references directly, then any component changes. Causes the entire component tree from the Provider to be re-rendered.
Function App() {const [state, dispatch] = useReducer(reducer, init) return () <GlobalContext.Provider value={{... state, dispatch}}> {/* child node */} </GlobalContext.Provider> ) }Copy the code
If you want to avoid these problems, you’ll need to wrap another layer, which is no different from using Redux directly.
Context update is a high performance operation. When the Provider detects a change, it traverses the entire Fiber tree to check whether each Consumer needs to be updated.
Redux Mobx is a great way to avoid this problem.
How to write a Store
If you know Redux, you should know how he defines a Store. There are some good best practices, but there are still a lot of problems and problems in production. So there were a lot of Redux libraries that basically claimed to simplify API usage and concepts, but they actually added a lot of complexity, introduced namespaces/modals, I don’t remember, but they were automatically told off, Don’t like to make things more difficult to simplify than they already are.
On the Mobx side, officials have a good best practice. I think it is very reasonable, and is very easy to understand.
However, there are still some places where the authorities do not carry out too many restrictions, and similar problems have been encountered in the development process, so here are some comments and suggestions based on the official framework:
- Make sure that all changes to the store are performed inside the store. This means that you must update the store by calling the Action method on the store.
- Ensure store serializable, easy to use SSR and some debug functions
- The first argument to the class constructor is always the initialized data and is guaranteed to be of the same type as the return value of toJSON
- If the Store does not define a toJSON method, make sure there are no non-serializable types of data in the store, such as functions, DOM, Promises, and so on. Because if you don’t define json.stringify by default, you go with the built-in logic
- For example, if ThemeStore depends on GlobalStore, you simply pass in an instance of GlobalStore in the ThemeStore construction argument. But at this point, some of you might think that this is the manual version of DI. Yes, DI is a good design pattern, but it’s light on the front end, so you don’t need to import libraries to manage it, you can manage it manually. It is also easy to implement Redux’s concept of namespace and sub-stores
- If you are developing with TS, it is recommended that you separate the implementation from the definition, that is, define an interface and a class, and the class inherits the interface so that only the interface needs to be exposed externally (i.e. internally). This makes it easy to hide methods that you don’t want exposed to the outside world, but still need to use internally. Again, if GlobalStore has a property that the ThemeStore needs to fetch, but not the component, you can define the method on the class instead of the interface for good type checking and some isolation.
Yeah, basically that’s how you write a Store, no fancy concepts, no messy tools, just conventions. I always believe that no rules is the biggest rules, no constraints is the biggest constraints. A lot of things can be established by convention, on paper is enough. There is no need to make a bunch of Lint /tools/library constraints, which increases both the upfront development cost and the later maintenance cost. Just ask how many dead tools and libraries you have in your company.
As the saying goes, “Qin people are not free from sorrow and posterity sorrow, posterity sorrow and not mirror, also make posterity and sorrow posterity”, this is the status quo (a slap to wake up)
However, the above premise requires that your development team has enough development ability, otherwise there are many new users or synchronization agreement cost is high, it is not impossible to make a library to restrict.
Weaknesses?
Said so much, also is not to say omnipotent, have this several shortcomings
- Native hooks are not as good for performance as some stateful widgets. Use native hooks or Mobx hooks for components, depending on the business situation. And for widgets, the amount of code may increase accordingly. Because it has to be wrapped every time
useObserver
Methods. - Mobx, for now, doesn’t have a good chance of using asynchronous rendering in the future, although I don’t think it makes much sense. It’s kind of a smoke screen, but it’s worth trying.
- You need to have some background in mobx, if you start writing directly, you will avoid hooks, but you will get a lot of Mobx hooks
conclusion
Mobx has been used in our project for a long time, but Hooks are new. You are also welcome to speak out your problems and find solutions together.
I have always felt that Mutable development is always easy to understand and least difficult to learn, while Immutable development is easy to maintain and stable. Mobx + React can be considered a good combination of the two. It is possible to use Immutable where performance is needed and fast development where performance is not.
Of course, you don’t need to use Mobx. After getting used to it, I will use native Hooks for minor projects.