mobxjs / mobx-state-tree

Full-featured reactive state management without the boilerplate
https://mobx-state-tree.js.org/
MIT License
6.93k stars 641 forks source link

Feature - effects #867

Closed AjaxSolutions closed 2 months ago

AjaxSolutions commented 6 years ago

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?

mattruby commented 6 years ago

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 notifications@github.com 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- mattruby@gmail.com

AjaxSolutions commented 6 years ago

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.

mweststrate commented 6 years ago

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

mweststrate commented 6 years ago

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

jayarjo commented 6 years ago

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

jayarjo commented 6 years ago

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

jayarjo commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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

xaviergonz commented 6 years ago

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)

jayarjo commented 6 years ago

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

AjaxSolutions commented 6 years ago

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

Amareis commented 5 years ago

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
    }))
  2. Add affect(node) function in mst core, which recursively run all effects in tree and marks node as affected recursively.
  3. Add unaffect(node) function in mst core, which dispose all effects in tree and remove affected mark recursively.
  4. Add isAffected(node) function in mst core (you guessed, it returns true if node is affected!).
  5. Internally, we always unaffect node before destroying.
  6. Node is not unaffected on detach - user can do it himself if needed.
  7. 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 commented 5 years ago

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 commented 5 years ago

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.

mweststrate commented 5 years ago

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)

Amareis commented 5 years ago

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?

xaviergonz commented 5 years ago

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 commented 5 years ago

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 commented 5 years ago

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

xaviergonz commented 5 years ago

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

Amareis commented 5 years ago

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.

xaviergonz commented 5 years ago

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

Amareis commented 5 years ago

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.

xaviergonz commented 5 years ago

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:

The good thing is that this would be possible:

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

Amareis commented 5 years ago

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.

k-g-a commented 5 years ago

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 commented 5 years ago

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

xaviergonz commented 5 years ago

I guess customEffect could be just effect yes

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

what do you mean?

Amareis commented 5 years ago

Anyway we need some internal function to start/stop effects. Why don't give it to users? I can imagine what some user need to detach some existing subtree and run effects on it again, or disable effects in subtree under some circumstances.

luisherranz commented 5 years ago

We're using some mobx effects (autorun, reaction...) in afterCreate but when we upgraded to MST3 we found some of them weren't triggered on creation but on access, due to the lazy mode introduced in MST3. That introduced some bugs and we had to "force" the access to launch those afterCreates.

Don't get me wrong, lazy mode is awesome (out .create() now takes 6 times less!) but it doesn't play nice with effects. Maybe if runEffects is true it should disable lazy mode? Or maybe a new option should be added, lazy, true by default so devs can control it?

By the way, following this reasoning, afterCreate in lazy mode is a bit confusing. Maybe afterAccess or afterFirstAccess would be easier to understand.

jayarjo commented 5 years ago

Ehm... I still miss a point sorry. Has anyone here looked at this branch? And if yes, could you please explain to me:

  1. why can't we simply attach reactions when node is instantiated, rather than on afterCreate/afterAttach? my understanding was that all children of the node will be instantiated bby that moment.
  2. node is monitored only if reactions object is not empty and disposers are added via addDisposer to the internal cache, that is auto-purged when node is destroyed, so that's good?
  3. also suggested api provides access not only to new, but also to the old value of the monitored observable or computable property, isn't it handy?
Amareis commented 5 years ago

Effects is more general than watchers, so we can implement watchers with access to old values on effects base. Dedicated effects also removes some boilerplate and code will be more decoupled (effects will not explodes afterCreate , but placed at own section).

xaviergonz commented 5 years ago

@jayarjo it is certainly more akin to watch props, but what if you want to react to a child component prop from the parent?

For example, say that you want to trigger a reaction inside the todoStore when a todo is added (like making a rest call to save it on the server)

Then also, you might want to disable that API call when you are inside tests (although I guess a way around this would be to use getEnv to avoid those)

As for attaching reactions when a node is instantiated, I guess that makes sense at first, but might be reacting to "initialization" changes in an "unfinished" node since there might be be more init code inside those hooks.

Dunno, generalizing this is hard 💃

I'm starting to think it is just easier to have effect function that returns a disposer for early disposing and where the automatic disposing gets triggered depending on where you call the function

.actions((self) => {
  // created here (initialization phase) means auto dispose on beforeDestroy, ran before afterCreate/afterAttach
  effect(self, reaction(.....))

  return {
    afterCreate() {
      effect(self, reaction(...)) // will auto-dispose on beforeDestroy
    },
    afterAttach() {
      effect(self, reaction(....)) // will auto-dispose on beforeDetach
    },
    someOtherAction() {
      effect(self, ....) // or anywhere else: throw? allow and dispose on beforeDestroy to allow adding effects to a node when already created?
    }
  }
})

where effect can also be used for custom stuff

effect(self, () => { ... whatever returns a disposer })

and if the effect has to run only on certain conditions the user can just if/else based on getEnv, a property, a property of the root, process.env.NODE_ENV or whatever he wants

this would effectively be a "smarter" replacement of addDisposer

k-g-a commented 5 years ago

@xaviergonz I mean that if one describes effects on model type declaration, why would he want to turn those on or off?

const MyModel = types.model({props}).views(...).actions(...);
const MyModelWithEffects = MyModel.reaction(...);
/* or even */
const MyModel1 = types.model({props});
const MyModel2 = types.model({props});
const MyEffects = types.model().reaction(...);
const MyModelWithEffects1 = types.compose(MyModel1, MyEffects);
const MyModelWithEffects2 = types.compose(MyModel2, MyEffects);

/* and then instead of calling */
MyModelWithRunEffectsSetting.create({snapshot}, {runEffects: CONDITION})
/* one could just */
const Factory = CONDITION ? MyModelWithEffects1 : MyModel1;
Factory.create({snapshot});

Such approach makes code more self-documented as it implies concrete knowledge whether this particular instance contains effects or not. Meanwhile relying on ancestor's setting up the tree you will end up debugging your "supposed-to-run" effects in runtime - just to find out which of those ancestors accidantaly turned them off. Additionally, using composition will give you proper types for free: effects will be listed for models which have those and won't - for the rest.

For those who really want to have such a 'setting', it's not hard to implement (pseudocode):

const AutoEffectsModelBase = types
  .model()
  .volatile(self => ({
    __effectsEnabled: true
  }))
  .actions(self => ({
    afterCreate() {
      getMembers(self).effects.forEeach(key => {
        const originalEffect = self[key];
        self[key] = (data, reaction) => !self.__effectsEnabled && originalEffect(data, reaction);
      })
    },
    //automatic, can be enhanced in any way (i.e. through getEnv() or process.env.NODE_ENV)
    afterAttach() {self.__effectsEnabled = true},
    beforeDetach() {self.__effectsEnabled = false},
    //manual
    enableEffects() {self.__effectsEnabled = true}, 
    disableEffects() {self.__effectsEnabled = false}
  }))

@jayarjo , considering the branch you've mentioned: it narrows down every particular reaction to one field. So if one needs to react to subset of fields, he must first define a computed property (view), which just uses all the data needed, and then add a reaction to that view. This is actually not far from custom reaction at afterCreate.

xaviergonz commented 5 years ago

@k-g-a OK, maybe custom cases can be left for custom implementations, and certainly using models with and without effects can be controlled by using an extended model or not.

(still for these custom cases I think adding to add Disposer a parameter where you can configure on which phase they will be disposed would be cool 😎)

but on which lifecycle phase should they be started in your opinion?

'Additionally, using composition will give you proper types for free: effects will be listed for models which have those and won't - for the rest.'

why would effects need to be in the typings? they are kind of internal functions (unless you don't mean model instance typescript typings 😁)

xaviergonz commented 5 years ago

off topic : maybe there could be an afterInitialized hook with docs rather than expect people to know that stuff that runs on the top of views / actions /extends functions run on that phase?

jayarjo commented 5 years ago

So you guys are looking for something more exhaustive, than what this thread was originally about. I think that anything more complex than what I referenced in the branch doesn't worth the alternative implementation and will only confuse things.

afterCreate/afterAttach() {
      addDisposer(self, autorun/reaction(...));
}

Is already simple enough for all the cases beyond watching separate properties. IMHO.

luisherranz commented 5 years ago

I think it's a good opportunity to create an easier API for a common pattern :)

And maybe solve the lazy problem along with it.

Amareis commented 5 years ago

Main purpose of separated effects is improved readability and structure of code, I think.

Okay, maybe we went too deep for beginning of such complicated feature? Lets done something stupid and obvious and then improved that when users will complain about.

For example, only add chainable effects function, which is tiny wrapper on top of actions. User can calls effect (just as action) and it will automatically scheduled to dispose on destroy. Also on effect in runtime there is running property and stop() function, so user can manage them manually. He can run it on lifecycle hooks or from actions and stop if needed. And, if there is effects on model, it's not lazy.

xaviergonz commented 5 years ago

something like:

.effects(self => ({
  whateverFx: reaction(() => self.foo, () => {...})
}), { autoStart: true/false} /* defaults to true*/)

and then access them through

getEffects(node).whateverFx.(running/stop()/start()) ?

??

About lazy, makes me wonder: Since effects actually access props they want to react to and props accessed are auto-instantiated, does it really matter if they are lazy?

Also, again, what's a use case for manual stop, checking if it is running, etc? I'd love to see an example.

Amareis commented 5 years ago

No, even more stupid.


.effects(self => ({
  whateverFx: () => reaction(() => self.foo, () => {...})
})
.actions(self => ({
  afterAttach() {
    self.whateverFx() //run the effect, schedule it to be disposed on destroy
  },
  someAction() {
    if (self.whateverFx.running)
      self.whateverFx.stop()  //dispose effect
   //else effect will be disposed on destroy
  }
}))
Amareis commented 5 years ago

In that case all is very explicit and user can do all sort of things that are possible with raw reactions, but effects are decoupled from actions and there is ways for next improving.

xaviergonz commented 5 years ago

fair enough by me :) just a small change self.whateverFx.start() //run the effect, schedule it to be disposed on destroy since in the mobx world calling a function is usually akin to disposing

my only reservation is that I don't see it much of a change over self.addDisposer

Amareis commented 5 years ago

May be, not the point.

пт, 26 окт. 2018 г., 1:53 Javier Gonzalez notifications@github.com:

fair enough by me :) just a small change self.whateverFx.start() //run the effect, schedule it to be disposed on destroy

— You are receiving this because you commented. Reply to this email directly, view it on GitHub https://github.com/mobxjs/mobx-state-tree/issues/867#issuecomment-433201766, or mute the thread https://github.com/notifications/unsubscribe-auth/AElX2XX1FfgGiXkynsAE8D1GVEjKGHtXks5uoiTWgaJpZM4UlHtu .

luisherranz commented 5 years ago
 afterAttach() {
    self.whateverFx.start()
  },

The problem if we rely on afterAttach is that there's no way to be sure that an effect will run after model creation.

About lazy, makes me wonder: Since effects actually access props they want to react to and props accessed are auto-instantiated, does it really matter if they are lazy?

That makes perfect sense, the effect could observe the props of the instance and therefore that instance would be created/attached. So no need to mark it as no-lazy by default. – But this won't work if we rely on afterAttach as I explained above.

Amareis commented 5 years ago

This is why i wrote about disabling lazy mode if effects are declared on model. So, basically, effects doing 2 things - more decoupled code and solves lazy problem.

luisherranz commented 5 years ago

I'm not sure if disabling lazy mode when effects are present is the desired behaviour. Imagine this pattern:

const Parent = types.model('Parent', {
  children: types.array(Child),
  allChildrenActive: false
})

const Child = types.model('Child', {
  active: false
})
.effects(self => ({
  activateOnParentActivation: autorun(() => {
    if (getParent(self).allChildrenActive === true)
      self.active = true
  })
})

Sorry not to come up with a more realistic example, but if I'm not mistaken this effect would instantiate the parent but none of the children (as long as they are not observed) because the effect is observing the parent and not itself. So, it would still work fine, but it'd be more performant as the laziness would be still linked to runtime observation.

Amareis commented 5 years ago

I think that there is no place for laziness if any side effects in a game. But for similar cases there is can be some sort of immediate-on-instance-effects.