mobx-state-tree
mobx-state-tree copied to clipboard
Feature - effects
I'm new to MST, so forgive me if my question has an obvious answer.
Vue has computed properties similar to MST's views, but it also has watch properties.
If you want to learn about watch properties, here's the Vue doc: https://vuejs.org/v2/guide/computed.html#Computed-vs-Watched-Property
Also, watch this video, scroll to the 28:00 mark: https://youtu.be/UHmFXRp0JDU?t=1691
Would it be possible to add watch properties to MST?
See: https://mobx.js.org/refguide/reaction.html and https://mobx.js.org/refguide/autorun.html
On Tue, Jun 12, 2018 at 3:41 PM, Les Szklanny [email protected] wrote:
I'm new to MST, so forgive me if my question has an obvious answer.
Vue has computed properties similar to MST's views, but it also has watch properties.
If you want to learn about watch properties, here's the Vue doc: https://vuejs.org/v2/guide/computed.html#Computed-vs-Watched-Property
Also, watch this video, scroll to the 28:00 mark: https://youtu.be/UHmFXRp0JDU?t=1691
Would it be possible to add watch properties to MST?
— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/mobxjs/mobx-state-tree/issues/867, or mute the thread https://github.com/notifications/unsubscribe-auth/AAIrcnkC_h_PNUpYn1Sxqj72a6BWPiDFks5t8CeGgaJpZM4UlHtu .
-- -Matt Ruby- [email protected]
Thanks. I think adding reactions/watchers to MST alongside views and actions would be a good feature even though this is already available in MobX.
I think this is a pretty cool idea, because it would save some boilerplate around disposers.
Big question is when should those reactions be setup. afterAttach or afterCreate? AfterAttach is not always triggered (not for roots) or multiple times (when moving nodes, which is not too common), afterCreate however does not give access to parents yet
I was wondering about the same thing recently. I will see if I can PR anything meaningful here.
I'm afraid I will need a hint on why something like this might not be a good idea.
It is not clear how to behave if the property to watch is observable array or map. What exactly should the listener receive? newValue
and oldValue
won't be any much meaningful in such context.
I checked what Vue does in such cases and I think they still supply newValue
and oldValue
despite of them being useless, as they reference the same object.
Note: when mutating (rather than replacing) an Object or an Array, the old value will be the same as new value because they reference the same Object/Array. Vue doesn’t keep a copy of the pre-mutate value.
Although not sure if we can do better and keep the immediate simplicity at the same time. @AjaxSolutions what would you expect for example?
Maybe it has sense to provide full change
object instead of newValue/oldValue
pair. Then users could use spread
operators to extract only required properties.
Until someone suggests a better solution I thought it could simply give the full change object to the listener, unless listener explicitly requests two or more arguments (correspondingly newValue
, oldValue
and an optional changeType
).
@mweststrate I guess your comment on https://github.com/mobxjs/mobx-state-tree/commit/1dfe7f4d691b2febe7d9c1fc5ff52157d22d39e2 branch is still very appreciated or anyones with comparable insight into the inner wirings of MST, 'cause I should have overlooked whole lot (types is surely a definite one that I missed).
why not just offer inside self for model types a reaction/autorun method that will add the disposer to some registry that gets disposed on destroy and let the user decide when to call it? (usually inside afterattach or aftercreate)
@xaviergonz what would be the benefit comparing to the solution in the draft? Also don't we have it already through reaction/autorun
imports and addDisposer
method - one could invoke these from whatever place he likes.
If possible, I'd like this feature to work exactly as in Vue, which IMO is easier to use than autorun or reaction.
See the options provided by Vue's watchers. https://vuejs.org/v2/api/#watch
This should cover 80% of use cases. For more complex requirements you can still use autorun or reaction.
See this tweet by the Evan You - the Vue creator. I agree with him that Vue is easier to use than React in part because of watch properties.
https://twitter.com/youyuxi/status/736939734900047874?lang=en
In mobx-react there is disposeOnUnmount
decorator now, which handles similar issue. But in react's observer
components there is no problem with moment when start watching - at component creation you already have the mst tree (or mobx store).
How @mweststrate says, mst node cannot just start watching (I prefer "reacting" word, it closer to mobx universe) at contruction or at afterCreate/afterAttach - because, in general, it can reacts to whole tree, includes parents and neighbors, so node should wait for full ready tree, and, currently, there is simply no way to know about (because it depends on user intentions).
So, what we can do with it? In mobx there is three main concepts: observables, computed and, in end (or in begin?.., huh interesting question), reactions. First two also exists in mst, but for reactions there is no any analogs - we just uses plain mobx reactions, as recommended in docs.
Wait! If mst is living tree, as it described in docs, why it cannot reacts to anything? It's more like a potted tree which dies quickly without the gardener! ;)
I think, lack of side effects tools (I prefer "main effects" word, because, usually, all interesting things are happens here) is big gap for state management library. If we look at redux, there is redux-saga - side effects library. In our main application, which now in migration from redux to mst, we heavily use sagas for big part of business logic, because pure and synchronous way of redux simply doesn't fit the real world.
Of course, bunch of autoruns and reactions in some file can handle all of these effects, but why we cannot embed it into mst? There is pretty straigtforward way to do it:
- Add
effects
chainable method, just asactions
orviews
. Effect is function which subscribes to something and return itself disposer - right, just as autorun, but also it can be just some addEventListener/removeEventListener pairs or something else. Of course, effects should use actions for tree changing.
types.model('Todo', {
id: types.identifier,
title: types.string,
finished: false,
}).actions(self => ({
update: (newTodo) => Object.assign(self, newTodo),
})).effects(self => ({
notifyBackend: () => autorun(() => api.setTodoFinished(self.id, self.finished)),
listenBackend: () => {
api.listenTodoChanges(self.id, newTodo => self.update(newTodo))
return () => api.stopListenTodoChanges(self.id) //just an example, there is can be some removeEventListener or anything disposer-like
}))
- Add
affect(node)
function in mst core, which recursively run all effects in tree and marks node asaffected
recursively. - Add
unaffect(node)
function in mst core, which dispose all effects in tree and removeaffected
mark recursively. - Add
isAffected(node)
function in mst core (you guessed, it returns true if node isaffected
!). - Internally, we always
unaffect
node before destroying. - Node is not
unaffect
ed on detach - user can do it himself if needed. - Attaching not
affected
node toaffected
parent is special case (when user make something likeself.todos.push({title: 'New todo'})
, if todos isaffected
, new todo will constructed and it's notaffected
yet, but it should beaffect
ed after attach in most use cases). In such case node will beaffect
ed and if user wants to avoid this, he should explicit invokesunaffect(self)
inafterAttach
hook (effects will not even started in that case).
I think this concept is simple enough for understanding, implementing and maintaining. What are you thinks? @mweststrate @AjaxSolutions @jayarjo @xaviergonz
Also, affect
and unaffect
can use optional second argument, which disables recursion. For example, on node destroying there is no mean to recursive unaffect
- child nodes just will invoke unaffect
at own destroying.
So, can anybody review my request? I can make a PR, but there is recommendation in readme - discuss extensive changes in issues first.
Some examples. Currently, I have that code (it's with classy-mst, but it doesn't important):
class ItemCode extends shim(ItemData) {
subs: any = []
@action
afterAttach() {
this.subs = [
app.api.chatUpdated(c => c.id === this.id, this.merge),
app.api.messageCreated(m => m.chat === this.id, m => this.insertMessages([m])),
app.api.todoCreated(t => t.chat === this.id, this.addTodo),
]
}
@action
beforeDestroy() {
this.subs.forEach((unsub: Function) => unsub())
}
//...
}
With some sort of autodisposed effects or watchers (which is special case of effect) this code can be rewritten to:
class ItemCode extends shim(ItemData) {
@effect
subscribe() {
return [
app.api.chatUpdated(c => c.id === this.id, this.merge),
app.api.messageCreated(m => m.chat === this.id, m => this.insertMessages([m])),
app.api.todoCreated(t => t.chat === this.id, this.addTodo),
]
}
//...
}
Another effects, which I manage manually now, also can be pulled out to different, explicitly marked as effect, methods, so code will be more transparent and antifragile.
To fix the 'at what point in the lifecycle' problem, would it be fine to set up effects on the next tick?
(also, minor correction on the above reasoning: side effects are first class in MST, through for example actions and flows, just automatic side effects are generalized into their own API)
Next tick after creating? Yeah, it should works. In that case we don't need to explicit call affect
, only unaffect
node after creation if we don't want to start effects automatically.
So,
Todo.create({title})
- effects are started at next event loop.
let todo = Todo.create({title}); unaffect(todo)
- effects will not started until explicit affect
call. Same behaviour if user invokes unaffect(self)
in afterCreate
or, if node attached immediatly after creating, in afterAttach(self)
.
Should we add separated willAffected
function to core for detect planned to affect
nodes?
I like the idea of effects, why the need for names though? it could just be an array (or both object and array could be supported)
About the implementation, why not just hook it into the afterCreate / beforeDestroy events (or afterAttach / beforeDeatch) and if somebody wants something more custom let him do it hooking it himself through any of the other hooks?
With an options argument to effects declaring when you want them to be run
.effects(...., { mode: 'onCreate' | 'onAttach' }) // defaulting to onCreate
if the user wants different effects for different lifecycles he can just call effect twice with different options
Then again I'm not sure how custom events are gonna get along with lazy instantiation.
Just wondering, why do effects need to be run on the next tick? can't they just run right after "afterCreate"/"afterAttach" are finished?
can't they just run right after "afterCreate"/"afterAttach" are finished?
I think you're right, but also there is should be a third mode, manually
or something like it, which runs only at explicit user intention (direct call or affect
at node).
Also, effect should be a functions which returns disposer. In that way user can runs/stops they just as simple functions and it will be easier to stop/rerun all effects on node.
What's the use case for manual triggering?
As for choosing if effects should be run or not it could be done on create
.create(..., {runEffects: true})
// true (or maybe false?) by default
Ok, I think third mode can be added after some battle testing, if needed. runEffect should be true by default, because create can be called implicitly at snapshot converting.
Ok, a couple of points, I'd totally not have a mode (create or after attach), and this is why:
runEffect for implicit conversions could be inherited from the value when it was used on create of the parent e..g
const m = X.create(..., {runEffects: true}}/ force run on itself and children that do not specify a preference
m.child = {...} or Child.create() // effects will run (inherited)
m.child = Child.create(..., {runEffects: true}) // will run for itself and children that do not specify a preference
m.child = Child.create(..., {runEffects: false}) // won't run on itself and children that do not specify a preference
const m = X..create(..., {runEffects: false}) // force not run on itself and children
m.child = {...} or Child.create() // effects won't run (inherited)
m.child = Child.create(..., {runEffects = true}) // will run for itself and children that do not specify a preference
m.child = Child.create(..., {runEffects = false}) // won't run on itself and children that do not specify a preference
and if there's no runEffects set and no parent the it will assume undefined
why undefined?
const parent = Parent.create(..., {runEffects: true}) // effects will be run as requested, in this case as part of "afterCreate"
const child = Child.create(...) // inherit, but we don't know if it will be a root node or not, but since the default is undefined we won't run them right now
parent.setChild(child) // aha, now we know what the inheritance asks of it, so we run the effects (albeit admittedly as part of an "attach" event
summarizing:
- runEffects: true - run them on creation (after afterCreate is done), dispose on beforeDestroy
- runEffects: false - do not ever run them
- runEffects: undefined (default) - on afterAttach... (dispose on beforeDetach)
-
- if the nearest parent with runEffect !== undefined is true - run them
-
- if the nearest parent with runEffect !== undefined is false - do not run them
-
- if there's no nearest parent with runEffect set - don't run them
-
- never attached - will wait until it is attached of course to decide
The good thing is that this would be possible:
- create a node C with runEffects = undefined
- C gets attached to P1, which has run effects set to true
- C runs its effects upon attachment
- C gets detached, effects are disposed
- C now gets attached to P2, which has run effects set to false
- no C effects are run as per parent "config"
- C now gets attached to P3, which has run effects set to true
- C runs its effects once more upon attachment
- P2 tree dies, C is detached, C disposes of its effects
Basically it is done for you and you don't have to think about it. The usual would just be to set runEffects to true upon creating the root node of the store.
I hope that makes sense.
That being said, wouldn't the api be more clear like this?
types.model(...)
.reaction((self) => self.id, (self, newValue) => { do whatever }, reactionOptions?)
.autorun((self) => { do whatever }, autorunOptions?)
.customEffect((self) => { return a disposer }, options?)
that'd be more akin to watch properties too
Also I'd also maybe add those methods to any complex type (map, array, model, etc), not only models (but of course not to simple types such as string), but that could be left for the future since those types don't have hooks right now.
And I guess the clone method would need options with runEffects too :)
Instead of customEffect
there is can be just effect
and reaction, autorun etc (onAction, may be, as suggested in #1056?) it's just sugar for effect
. Also, we need functions for start/stop effects manually (what if we detach node and want to effects don't stop, or stop effects on some subtree?) and one for detecting if effects are running.
I do incline to the latter proposal with API clearly mirroring mobx's one - it's dead simple and gives reasonable flexibility.
Single setting (runEffects
) looks acceptable and I can understand it's use-cases, although same result could be achieved by proper composition.
Best place to run effects seems to be ObjectNode.finalizeCreation
. We could introduce afterFinalized()
hook (as the last meaningful action of the method) and use it internally.
Can't think of any use cases for starting/stopping effects manually. It would be great to have an example.
I guess customEffect could be just effect yes
@k-g-a although same result could be achieved by proper composition.
what do you mean?