title | sidebar_label | hide_title |
---|---|---|
What does MobX react to? |
Understanding what MobX reacts to |
true |
MobX usually reacts to exactly the things you expect it to. Which means that in 90% of your use cases mobx "just works". However, at some point you will encounter a case where it might not do what you expected. At that point it is invaluable to understand how MobX determines what to react to.
MobX reacts to any existing observable property that is read during the execution of a tracked function.
- "reading" is dereferencing an object's property, which can be done through "dotting into" it (eg.
user.name
) or using the bracket notation (eg.user['name']
). - "tracked functions" are the expression of
computed
, therender()
method of an observer component, and the functions that are passed as the first param towhen
,reaction
andautorun
. - "during" means that only those observables that are being read while the function is executing are tracked. It doesn't matter whether these values are used directly or indirectly by the tracked function.
In other words, MobX will not react to:
- Values that are obtained from observables, but outside a tracked function
- Observables that are read in an asynchronously invoked code block
To elaborate on the above rules with an example, suppose that you have the following observable data structure (observable
applies itself recursively by default, so all fields in this example are observable):
let message = observable({
title: "Foo",
author: {
name: "Michel",
},
likes: ["John", "Sara"],
})
In memory that looks as follows. The green boxes indicate observable properties. Note that the values themselves are not observable!
Now what MobX basically does is recording which arrows you use in your function. After that, it will re-run whenever one of these arrows changes; when they start to refer to something else.
Let's show that with a bunch of examples (based on the message
variable defined above):
autorun(() => {
console.log(message.title)
})
message.title = "Bar"
This will react as expected, the .title
property was dereferenced by the autorun, and changed afterwards, so this change is detected.
You can verify what MobX will track by calling trace()
inside the tracked function. In the case of the above function it will output the following:
const disposer = autorun(() => {
console.log(message.title)
trace()
})
// Outputs:
// [mobx.trace] 'Autorun@2' tracing enabled
message.title = "Hello"
// [mobx.trace] 'Autorun@2' is invalidated due to a change in: 'ObservableObject@1.title'
It is also possible to get the internal dependency (or observer) tree by using the designated utilities for that:
getDependencyTree(disposer) // prints the dependency tree of the reaction coupled to the disposer
// { name: 'Autorun@4',
// dependencies: [ { name: 'ObservableObject@1.title' } ] }
autorun(() => {
console.log(message.title)
})
message = observable({ title: "Bar" })
This will not react. message
was changed, but message
is not an observable, just a variable which refers to an observable,
but the variable (reference) itself is not observable.
var title = message.title
autorun(() => {
console.log(title)
})
message.title = "Bar"
This will not react. message.title
was dereferenced outside the autorun
, and just contains the value of message.title
at the moment of dereferencing (the string "Foo"
).
title
is not an observable so autorun
will never react.
autorun(() => {
console.log(message.author.name)
})
message.author.name = "Sara"
message.author = { name: "John" }
This will react to both changes. Both author
and author.name
are dotted into, allowing MobX to track these references.
const author = message.author
autorun(() => {
console.log(author.name)
})
message.author.name = "Sara"
message.author = { name: "John" }
The first change will be picked up, message.author
and author
are the same object, and the .name
property is dereferenced in the autorun.
However the second change will not be picked up, the message.author
relation is not tracked by the autorun
. Autorun is still using the "old" author
.
const message = observable({ title: "hello" })
autorun(() => {
console.log(message)
})
// Won't trigger a re-run
message.title = "Hello world"
In the above example, the updated message title won't be printed, because it is not used inside the autorun.
The autorun only depends on message
, which is not an observable, but a constant. In other words, as far as MobX is concerned, title
is not used in- and hence not relevant for the autorun
The fact that console.log
will print the message title is misleading here; console.log
is an asynchronous api that only will format its parameters later in time, for which reason the autorun won't be tracking what data the console.log is accessing. For that reason make sure to always pass immutable data or defensive copies to console.log
.
The following solutions however, will all react to message.title
:
autorun(() => {
console.log(message.title) // clearly, the `.title` observable is used
})
autorun(() => {
console.log(mobx.toJS(message)) // toJS creates a deep clone, and thus will read the message
})
autorun(() => {
console.log({ ...message }) // creates a shallow clone, also using `.title` in the process
})
autorun(() => {
console.log(JSON.stringify(message)) // also reads the entire structure
})
autorun(() => {
console.log(message.likes.length)
})
message.likes.push("Jennifer")
This will react as expected. .length
counts towards a property.
Note that this will react to any change in the array.
Arrays are not tracked per index / property (like observable objects and maps) but as a whole.
autorun(() => {
console.log(message.likes[0])
})
message.likes.push("Jennifer")
This will react with the above sample data, array indexers count as property access. But only if the provided index < length
.
MobX will not track not-yet-existing indices or object properties (except when using maps).
So always guard your array index based access with a .length
check.
autorun(() => {
console.log(message.likes.join(", "))
})
message.likes.push("Jennifer")
This will react as expected. All array functions that do not mutate the array are tracked automatically.
autorun(() => {
console.log(message.likes.join(", "))
})
message.likes[2] = "Jennifer"
This will react as expected. All array index assignments are detected, but only if index <= length
.
autorun(() => {
message.likes
})
message.likes.push("Jennifer")
This will not react. Simply because the likes
array itself is not being used by the autorun
, only the reference to the array.
So in contrast, messages.likes = ["Jennifer"]
would be picked up; that statement does not modify the array, but the likes
property itself.
autorun(() => {
console.log(message.postDate)
})
message.postDate = new Date()
MobX 4
This will not react. MobX can only track observable properties, and 'postDate' has not been defined as observable property above.
However, it is possible to use the get
and set
methods as exposed by MobX to work around this:
autorun(() => {
console.log(get(message, "postDate"))
})
set(message, "postDate", new Date())
MobX 5
In MobX 5 this will react, as MobX 5 can track not-yet existing properties.
Note that this is only done for objects created with observable
or observable.object
.
New properties on class instances will not be made observable automatically.
autorun(() => {
console.log(message.postDate)
})
extendObservable(message, {
postDate: new Date(),
})
This will not react. MobX will not react to observable properties that did not exist when tracking started.
If the two statements are swapped, or if any other observable causes the autorun
to re-run, the autorun
will start tracking the postDate
as well.
const twitterUrls = observable.map({
John: "twitter.com/johnny",
})
autorun(() => {
console.log(twitterUrls.get("Sara"))
})
twitterUrls.set("Sara", "twitter.com/horsejs")
This will react. Observable maps support observing entries that may not exist.
Note that this will initially print undefined
.
You can check for the existence of an entry first by using twitterUrls.has("Sara")
.
So for dynamically keyed collections, always use observable maps.
Since MobX 4 it is also possible to use observable objects as dynamic collection, if they are read / updated by using the mobx apis, so that mobx can keep track of the property changes. The following will react as well:
import { get, set, observable } from "mobx"
const twitterUrls = observable.object({
John: "twitter.com/johnny",
})
autorun(() => {
console.log(get(twitterUrls, "Sara")) // get can track not yet existing properties
})
set(twitterUrls, { Sara: "twitter.com/horsejs" })
See the object manipulation api for more details
function upperCaseAuthorName(author) {
const baseName = author.name
return baseName.toUpperCase()
}
autorun(() => {
console.log(upperCaseAuthorName(message.author))
})
message.author.name = "Chesterton"
This will react. Even though author.name
is not dereferenced by the thunk passed to autorun
itself,
MobX will still track the dereferencing that happens in upperCaseAuthorName
,
because it happens during the execution of the autorun.
autorun(() => {
setTimeout(() => console.log(message.likes.join(", ")), 10)
})
message.likes.push("Jennifer")
This will not react, during the execution of the autorun
no observables where accessed, only during the setTimeout
.
In general this is quite obvious and rarely causes issues.
A common mistake made with observer
is that it doesn't track data that syntactically seems parent of the observer
component,
but in practice is actually rendered out by a different component. This often happens when render callbacks of components are passed in first class to another component.
Take for example the following contrived example:
const MyComponent = observer(({ message }) => (
<SomeContainer title={() => <div>{message.title}</div>} />
))
message.title = "Bar"
At first glance everything might seem ok here, except that the <div>
is actually not rendered by MyComponent
(which has a tracked rendering), but by SomeContainer
.
So to make sure that the title of SomeContainer
correctly reacts to a new message.title
, SomeContainer
should be an observer
as well.
If SomeContainer
comes from an external lib, this is often not under your own control. In that case you can address this by either wrapping the div
in its own stateless observer
based component, or by leveraging the <Observer>
component:
const MyComponent = observer(({ message }) =>
<SomeContainer
title = {() => <TitleRenderer message={message} />}
/>
)
const TitleRenderer = observer(({ message }) =>
<div>{message.title}</div>}
)
message.title = "Bar"
Alternatively, to avoid creating additional components, it is also possible to use the mobx-react built-in Observer
component, which takes no arguments, and a single render function as children:
const MyComponent = ({ message }) => (
<SomeContainer title={() => <Observer>{() => <div>{message.title}</div>}</Observer>} />
)
message.title = "Bar"
A common mistake is to store local variables that dereference observables, and then expect components to react. For example:
@observer
class MyComponent extends React.component {
author
constructor(props) {
super(props)
this.author = props.message.author
}
render() {
return <div>{this.author.name}</div>
}
}
This component will react to changes in the author
's name, but it won't react to changing the .author
of the message
itself! Because that dereferencing happened outside render()
,
which is the only tracked function of an observer
component.
Note that even marking the author
component field as @observable
field does not solve this; that field is still assigned only once.
This can simply be solved by doing the dereferencing inside render()
, or by introducing a computed property on the component instance:
@observer class MyComponent extends React.component {
@computed get author() {
return this.props.message.author
}
// ...
Suppose that the following components are used to render our above message
object.
const Message = observer(({ message }) => (
<div>
{message.title}
<Author author={message.author} />
<Likes likes={message.likes} />
</div>
))
const Author = observer(({ author }) => <span>{author.name}</span>)
const Likes = observer(({ likes }) => (
<ul>
{likes.map((like) => (
<li>{like}</li>
))}
</ul>
))
change | re-rendering component |
---|---|
message.title = "Bar" |
Message |
message.author.name = "Susan" |
Author (.author is dereferenced in Message , but didn't change)* |
message.author = { name: "Susan"} |
Message , Author |
message.likes[0] = "Michel" |
Likes |
Notes:
- * If the
Author
component was invoked like:<Author author={ message.author.name} />
. ThenMessage
would be the dereferencing component and react to changes tomessage.author.name
. Nonetheless<Author>
would rerender as well, because it receives a new value. So performance wise it is best to dereference as late as possible. - ** If likes were objects instead of strings, and if they were rendered by their own
Like
component, theLikes
component would not rerender for changes happening inside a specific like.
MobX reacts to any existing observable property that is read during the execution of a tracked function.