mobx-state-tree icon indicating copy to clipboard operation
mobx-state-tree copied to clipboard

Feature - effects

Open AjaxSolutions opened this issue 6 years ago • 98 comments

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?

AjaxSolutions avatar Jun 12 '18 20:06 AjaxSolutions

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]

mattruby avatar Jun 12 '18 20:06 mattruby

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.

AjaxSolutions avatar Jun 12 '18 21:06 AjaxSolutions

I think this is a pretty cool idea, because it would save some boilerplate around disposers.

mweststrate avatar Jun 18 '18 11:06 mweststrate

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

mweststrate avatar Jul 12 '18 11:07 mweststrate

I was wondering about the same thing recently. I will see if I can PR anything meaningful here.

jayarjo avatar Jul 22 '18 09:07 jayarjo

I'm afraid I will need a hint on why something like this might not be a good idea.

jayarjo avatar Jul 22 '18 19:07 jayarjo

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.

jayarjo avatar Jul 28 '18 10:07 jayarjo

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?

jayarjo avatar Jul 28 '18 10:07 jayarjo

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.

jayarjo avatar Jul 28 '18 11:07 jayarjo

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).

jayarjo avatar Jul 28 '18 15:07 jayarjo

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 avatar Aug 25 '18 20:08 xaviergonz

@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.

jayarjo avatar Aug 26 '18 06:08 jayarjo

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

AjaxSolutions avatar Aug 26 '18 14:08 AjaxSolutions

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:

  1. Add effects chainable method, just as actions or views. 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
}))
  1. Add affect(node) function in mst core, which recursively run all effects in tree and marks node as affected recursively.
  2. Add unaffect(node) function in mst core, which dispose all effects in tree and remove affected mark recursively.
  3. Add isAffected(node) function in mst core (you guessed, it returns true if node is affected!).
  4. Internally, we always unaffect node before destroying.
  5. Node is not unaffected on detach - user can do it himself if needed.
  6. Attaching not affected node to affected parent is special case (when user make something like self.todos.push({title: 'New todo'}), if todos is affected, new todo will constructed and it's not affected yet, but it should be affected after attach in most use cases). In such case node will be affected and if user wants to avoid this, he should explicit invokes unaffect(self) in afterAttach 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

Amareis avatar Oct 20 '18 12:10 Amareis

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.

Amareis avatar Oct 20 '18 12:10 Amareis

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.

Amareis avatar Oct 23 '18 06:10 Amareis

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)

mweststrate avatar Oct 23 '18 07:10 mweststrate

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?

Amareis avatar Oct 23 '18 08:10 Amareis

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)

xaviergonz avatar Oct 23 '18 16:10 xaviergonz

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

xaviergonz avatar Oct 23 '18 16:10 xaviergonz

Then again I'm not sure how custom events are gonna get along with lazy instantiation.

xaviergonz avatar Oct 23 '18 16:10 xaviergonz

Just wondering, why do effects need to be run on the next tick? can't they just run right after "afterCreate"/"afterAttach" are finished?

xaviergonz avatar Oct 23 '18 16:10 xaviergonz

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.

Amareis avatar Oct 23 '18 17:10 Amareis

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

xaviergonz avatar Oct 23 '18 18:10 xaviergonz

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.

Amareis avatar Oct 23 '18 18:10 Amareis

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 :)

xaviergonz avatar Oct 23 '18 18:10 xaviergonz

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.

Amareis avatar Oct 23 '18 19:10 Amareis

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.

k-g-a avatar Oct 23 '18 19:10 k-g-a

Can't think of any use cases for starting/stopping effects manually. It would be great to have an example.

k-g-a avatar Oct 23 '18 20:10 k-g-a

I guess customEffect could be just effect yes

@k-g-a although same result could be achieved by proper composition.

what do you mean?

xaviergonz avatar Oct 23 '18 20:10 xaviergonz