mobxjs / mobx

Simple, scalable state management.
http://mobx.js.org
MIT License
27.55k stars 1.78k forks source link

🚀 Proposal: MobX 6: 🧨drop decorators,😱unify ES5 and proxy implementations, 💪smaller bundle #2325

Closed mweststrate closed 4 years ago

mweststrate commented 4 years ago

MobX 6

Hi folks, I've tinkered a lot about MobX 6 lately, so I want to layout the vision I have currently

Goals

🧨 1. Become compatible with modern ES standards

Let's start with the elephant in the room. I think we have to drop the support for decorators. Some have been advocating this for years, others totally love decorators. Personally I hate to let decorators go. I think their DX and conciseness is still unparalleled. Personally, I am still actively engaged with TC-39 to still make decorators happen, but we are kinda back to square one, and new proposal will deviate (again) from the implementation we already have.

Dropping decorators has a few advantages, in order of importance (imho)

  1. Become compatible with standard modern JavaScript Since fields have not been standardized with [[define]] over [[set]] semantics, all our decorator implementations (and the decorate utility) are immediately incompatible with code that is compiled according the standard. (Something TypeScript doesn't do, yet, by default, as for TS this is a breaking change as well, unrelated to decorators). See #2288 for more background
  2. MobX will work out of the box in most setups MobX doesn't work with common out-of-the-box setup in many tools. It doesn't work by default in create-react-app which is painful. Most online sandboxes do support decorators (MobX often being one of the few reasons), but it breaks occasionally. Eslint requires special setup. Etc. etc. Dropping decorators will significantly lower the entry barrier. A lower entry barrier means more adoption. More adoption means more community engagement and support, so I believe in the end everyone will win.
  3. Less way to do things currently it is possible to use MobX without decorators, but it is not the emphasized approached, and many aren't even aware of that possibility. Reducing the amount of different ways in which the same thing can be achieved simplifies documentation and removes cognitive burden.
  4. Reduce bundle size A significant amount of MobX is decorator chores; that is because we ship with basically three implementations of decorators (TypeScript, Babel, decorate). I expect to drop a few KB by simply removing them.
  5. Forward compatibility with decorators I expect it will (surprisingly) be easier to be compatible with decorators once they are officially standardized if there is no legacy implementations that need to be compatible as well. And if we can codemod it once, we can do that another time :)

The good news is: Migrating a code base away from decorators is easy; the current test suite of MobX itself has been converted for 99% by a codemod, without changing any semantics (TODO: well, that has to be proven once the new API is finalized, but that is where I expect to end up). The codemod itself is pretty robust already!

P.s. a quick Twitter poll shows that 2/3 would love to see a decorator free MobX (400+ votes)

😱 2. Support proxy and non-proxy in the same version

I'd love to have MobX 6 ship with both Proxy based and ES5 (for backward compatibility) implementations. I'm not entirely sure why we didn't combine that anymore in the past, but I think it should be possible to support both cases in the same codebase. In Immer we've done that as well, and I'm very happy with that setup. By forcing to opt-in on backward compatibility, we make sure that we don't increase the bundle size for those that don't need it.

P.S. I still might find out why the above didn't work in the past in the near future :-P. But I'm positive, as our combined repo setup makes this easier than it was in the past, and I think it enables some cool features as well, such as detection of edge cases.

For example we can warn in dev mode that people try to dynamically add properties to an object, and tell them that such patterns won't work in ES5 if they have opted-in into ES5 support.

💪 3. Smaller bundle

By dropping decorators, and making sure that tree-shaking can optimize the MobX bundle, and mangling our source aggressively, I think we can achieve a big gain in bundle size. With Immer we were able to halve the bundle size, and I hope to achieve the same here.

To further decrease the build, I'd personally love to drop some features like spy, observe, intercept, etc. And probably a lot of our low-level hooks can be set up better as well, as proposed by @urugator.

But I think that is a bridge too far as many already rely on these features (including Mobx-state-tree). Anyway I think it is good to avoid any further API changes beyond what is being changed in this proposal already. Which is more than enough for one major :). Beyond that, if goal 2) is achieved, it will be much easier to crank out new majors in the future :). That being said, If @urugator's proposal does fit nicely in the APIs proposed below, it might be a good idea to incorporate it.

4. 🛂Enable strict mode by default

The 'observed' one, that is.

🍿API changes

UPDATE 22-5-20: this issue so far reflected the old proposal where all fields are wrapped in instance values, that one hasn't become the solution for reasons explained in the comments below

This is a rough overview of the new api, details can be found in the branch.

To replace decorators, one will now need to 'decorate' in the constructor. Decorators can still be used, but they need to be opted into, and the documentation will default to the non-decorator version. Even when decorators are used, a constructor call to

class Doubler {
  value = 1 

  get double () {
    return this.field * 2
  }

  increment() {
    this.value++
  }

  constructor() {
    makeObservable(this, {
      value: observable,
      double: computed,
      increment: action
    })
  }
}

Process

Timeline

Whatever. Isolation makes it easier to set time apart. But from time to time also makes it less interesting to work on these things as more relevant things are happening in the world

CC: @fredyc @urugator @spion @Bnaya @xaviergonz

mweststrate commented 4 years ago

@olee I think the first one is neat. For the second one, that is probably only a couple of lines, so I will not will be worth the effort of separating it.

If everything is observable/action/computed by default, then we know which fields must not be made observable, because they must have been marked as ignored in superclass.

@urugator that assumes always using auto observable in the superclass, and it assumes having the superclass under control in the first place. Simple counter example, using makeAutoObservable on a class MyComponent extends React.Component could blow up a lot React internals we don't even know about.

olee commented 4 years ago

But the decorator support could be built tree-shakeable, I guess?

szagi3891 commented 4 years ago

https://blog.logrocket.com/new-decorators-proposal/?amp=1 This part is interesting: "Problems with JIT and decorators"

urugator commented 4 years ago

@mweststrate Idea: makeObservable - anything that isn't listed in decorator map is decorated with ignore. So when you call makeAutoObservable in subsclass it won't attempt to redecorate already decorated fields.

makeAutoObservable always throws when called from a subclass of parent that hasn't called makeObservable/makeAutoObservable. That is to prevent messing up with "uncontrolled" classes in general.

olee commented 4 years ago

makeObservable - anything that isn't listed in decorator map is decorated with ignore. So when you call makeAutoObservable in subsclass it won't attempt to redecorate already decorated fields.

This might not work well, because there could be class fields you don't know about. For example a ts field declaration like public unobservedValue?: number; in the parent class would not be picked up by this strategy if it does not have any decorator on it. But for fields that have an initialized value this might be an option.

However, the more I think about this whole solution with makeObservable in general, the more I see it possibly becoming incredible performance heavy.

I think it would be great to have a kinda PoC early to investigate how the performance of such a solution would compare to previous decorators. Could this be done maybe with current's mobx extendObservable as a test?

makeAutoObservable always throws when called from a subclass of parent that hasn't called makeObservable/makeAutoObservable. That is to prevent messing up with "uncontrolled" classes in general.

This should definitely not happen - it would prevent adding observable fields for subclasses where you do not have control over the parent class.

urugator commented 4 years ago

public unobservedValue?: number;

Hm, if I follow correctly, such field won't be picked even by makeAutoObservable, so it shouldn't break the parent, but it seems like another problem of it's own.

This should definitely not happen - it would prevent adding observable fields for subclasses where you do not have control over the parent class.

It wouldn't, the point is that you are forced to use makeObservable (be explicit about what will be observable) instead of makeAutoObservable. That is to somehow address "No auto decorating for subclassed classes", so it's not breaking things silently.

olee commented 4 years ago

Well you said

makeAutoObservable always throws when called from a subclass of parent that hasn't called makeObservable/makeAutoObservable

But I guess you rather mean that only makeAutoObservable should throw if the parent class didn't call makeAutoObservable? Because otherwise it is as I said - it would prevent adding observable fields to subclasses where the parent wasn't observable - which is just a different wording of the quote above, ain't it?

urugator commented 4 years ago
class NonObservable {};

class SubclassOfNonObservable extends NonObservable {
  constructor() {
    super();
    makeAutoObservable(this) // throws because parent didn't called makeObservable/makeAutoObservable
    makeObservable(this); // never throws - allows adding observable fields
  } 
}

class Observable {
  constructor() {
    // Any of
    makeAutoObservable(this)
    makeObservable(this)    
  }
}

class SubclassOfObservable extends Observable {
  constructor() {
    super();
    makeAutoObservable(this) // doesn't throw because parent called makeObservable/makeAutoObservable
    makeObservable(this); // never throws
  }
}
olee commented 4 years ago

Yeah that makes sense, thanks for the example 😅

unadlib commented 4 years ago

@mweststrate Maybe it's fine to use a proxy for this, but the downside is that the browser must support Proxy. What do you think about it?

class Observable {
  constructor() {
    return new Proxy(this, {
      defineProperty(target: any, key: any, descriptor: any) {
        // handle decorator
      },
    });
  }
}

class Foo extends Observable {
  @observable
  field1 = 'value1';

  field2 = 'value2';

  constructor() {
    super();
  }
}
mweststrate commented 4 years ago

Requiring a base class removes flexibility, for example using decorators in react component classes

Op di 7 apr. 2020 06:45 schreef Michael Lin notifications@github.com:

@mweststrate https://github.com/mweststrate Maybe it's fine to use a proxy for this, but the downside is that the browser must support Proxy. What do you think about it?

class Observable { constructor() { return new Proxy(this, { defineProperty(target: any, key: any, descriptor: any) { // handle decorator }, }); } } class Foo extends Observable { @observable field1 = 'value1';

field2 = 'value2';

constructor() { super(); } }

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/mobxjs/mobx/issues/2325#issuecomment-610186845, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAN4NBH3JOHOH3TMXU56E23RLK4V5ANCNFSM4LZQ2B5Q .

unadlib commented 4 years ago

Indeed, flexibility is reduced. It can only look like this below, and it looks okay.

class Foo {
  @observable
  field1 = 'value1';

  field2 = 'value2';

  constructor() {
    makeObservable(this);
  }
}

Requiring a base class removes flexibility, for example using decorators in react component classes Op di 7 apr. 2020 06:45 schreef Michael Lin notifications@github.com: @mweststrate https://github.com/mweststrate Maybe it's fine to use a proxy for this, but the downside is that the browser must support Proxy. What do you think about it? class Observable { constructor() { return new Proxy(this, { defineProperty(target: any, key: any, descriptor: any) { // handle decorator }, }); } } class Foo extends Observable { @observable field1 = 'value1'; field2 = 'value2'; constructor() { super(); } } — You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub <#2325 (comment)>, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAN4NBH3JOHOH3TMXU56E23RLK4V5ANCNFSM4LZQ2B5Q .

szagi3891 commented 4 years ago

@mweststrate You wrote on Twitter today that the new mobx will be very good. Which is incredibly good news :) What will it look like from the user's side? Will the new version eat less memory?

mweststrate commented 4 years ago

Too early to tell but I don't expect any significant memory gains.

Op za 11 apr. 2020 19:51 schreef Grzegorz Szeliga <notifications@github.com

:

@mweststrate https://github.com/mweststrate You wrote on Twitter today that the new mobx will be very good. Which is incredibly good news :) What will it look like from the user's side? Will the new version eat less memory?

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/mobxjs/mobx/issues/2325#issuecomment-612489057, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAN4NBHI56FCAMTG43ORPB3RMC32XANCNFSM4LZQ2B5Q .

robinplace commented 4 years ago

Have you considered an unopinionated MobX core build? Including just your basic TFRP functions (createAtom, computed, autorun/when/reaction, onBecomeObserved, and action) packaged up in a simple API might make for a lightweight and powerful alternative to RxJS and its friends.

That minimal set of functions should be enough to work with MobX React Lite and would let us sidestep the entire ES5 vs ES6 vs decorators question for a decent number of use-cases.

inoyakaigor commented 4 years ago

drop some features like spy, observe, intercept

Oh, no! I use both intercept and observe: 1) I have a store for statistics data with absolute values (count of emails sended, opened etc) and while I put the data to store the hook intercept calls a function which calculate relative data ( e.g. % opened/sended) 2) Also I have a store Parameters and there I use observe for changing url of current page after sone parameters changed

My key message is that stores in Mobx is… umm… smart. It can autorun, lazy compute etc so why it can't avaiblity to manipulate of incoming data? 🤔 Drop observe/intercept will make a store more dumb. That smart black magic is that feature why we really love Mobx. Isn't it?

mweststrate commented 4 years ago

They won't be dropped :-P. I was just wishful thinking :)

On Thu, Apr 16, 2020 at 4:15 PM Igor «InoY» Zvyagintsev < notifications@github.com> wrote:

drop some features like spy, observe, intercept

Oh, no! I use both intercept and observe:

  1. I have a store for statistics data with absolute values (count of emails sended, opened etc) and while I put the data to store the hook intercept calls a function which calculate relative data ( e.g. % opened/sended)
  2. Also I have a store Parameters and there I use observe for changing url of current page after sone parameters changed

My key message is that stores in Mobx is… umm… smart. It can autorun, lazy compute etc so why it can't avaiblity to manipulate of incoming data? 🤔 Drop observe/intercept will make a store more dumb. That smart black magic is that feature why we really love Mobx. Isn't it?

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/mobxjs/mobx/issues/2325#issuecomment-614716511, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAN4NBFRTLUX2VV7FH6NC63RM4OKJANCNFSM4LZQ2B5Q .

seivan commented 4 years ago
constructor () {
  makeObservable(this, { 
    field: observable,
    field2: observable.shallow,
    method: action,
  }
)

I also like this version more.

Is it possible to set TS to check that all fields that are not readonly must be marked as observable? It would also be nice for TS to check that we marked a field readonly with an observer and treat it as an error.

@szagi3891

Now assuming you mean Typescripts readonly keyword and/or Readonly<T> interfaces.

These ideas are bad for two reasons.

Regarding readonly keyword

A field being readonly, doesn't mean its content is immutable, it just means the field itself can't mutate. A use case would be readonly todos: Array<Todo>. You can't set a new array on it after the constructor assignment, but you most certainly can mutate the array itself.

Regarding Readonly<T> interface

There is a use case where you don't want to allow mutations outside of actions.

You could create private observables like private _todos: Array<Todo> but then also expose public computed getters. But that can easily be cumbersome if there are so many and has its own issues now[1]

Another approach is Readonly<Array<Todo>> with this

type Writeable<T> = { -readonly [P in keyof T]: T[P]};

class Store {
  readonly todos: Readonly<Array<Todo>>
  //assume set up in constructor 

  addTodo = (todo: Todo) => {
    const list: Writeable<Array<Todo>> = this.todos
    return list.push(readyTodo) 
  }
}

So I hope that readonly& Readonly<T> are not disabled from being observables. Though I would welcome a more succinct and non-intrusive approach for making private mutable observables & public immutable computed for a class that works out of the box.

[1]: My current issue is that there is no way to assign a decorator on private observables

class Store {
  private _todos: Array<Todo>
  //constructor setups
}

decorate(Store, {
  _todos: observable
})

Edit: Added solutions and workarounds for the issues brought up: https://github.com/mobxjs/mobx/issues/2340#issuecomment-619160940

trusktr commented 4 years ago

I know this issue is for MobX, but I'm interested in the topic of dropping decorators in general (until a proposal actually has success), and this issue happens to be one of the top results on Google when searching for terms relating to moving away from decorators and TypeScript.

@mweststrate Regarding the example with the method,

  increment = action(function () {
    this.value++
  })

and then in the constructor initializing the decoration: the [[HomeObject]] of the method will not be set, and we will not be able to access super unfortunately, or if we use arrow functions super will not work as we'd like in subclasses (we won't be able to extend methods). This is a hindrance for method use in the general sense.

Would perhaps the decoration step in the constructor also store the method on the prototype (only the first time) just to make it possible for a subclass to do something like

  incrementMore = action(function () {
    SuperClass.prototype.increment.call(this)
    this.value++
  })

as an alternative to using super?

I haven't used MobX before; maybe actions in MobX don't need to call super.method()s anyways, but I'm still curious about methods in general for any lib moving away from decorators.

One problem with the decorative values (for lack of a term of the above pattern) is with Custom Elements and decorating fields that should map to/from DOM attributes.

Decorators allow us to do things like modify static observedAttributes based on decorated properties before construction time. With the decorative value pattern (setting class fields with special values then calling initializeObservables(this) in class constructors), the decoration happens too late: customElements.define calls read values from observedAttributes immediately, before any instance of a passed-in class is ever created, therefore if initializeObservables(this) adds items into the observedAttributes array after the element is already defined those attributes will never be observed; we can not instantiate a custom element class before calling customElements.define (it will throw).

For the Custom Element case, we have to use the older pattern that MobX already uses, f.e. calling a function after the class definition:

class MyEl extends WebComponent(HTMLElement) {
  foo = 'bar'
  bar = 'baz'
}

mapAttributesToProps(MyEl, ['foo', 'bar']) 
// ^ The WebComponent base class may utilize meta data in attributeChangedCallback for example

Another way could be to rely on pre-defined observedAttributes values:

class MyEl extends WebComponent() {
  static observedAttributes = ['foo', 'bar']
  foo = 'bar'
  bar = 'baz'

  constructor() {
    super()
    mapAttributesToProps(this)
  }
}

or use the explicit decorative values to avoid clobbering super properties that weren't meant to be mapped:

class MyEl extends WebComponent() {
  static observedAttributes = ['foo', 'bar']
  foo = attribute('bar')
  bar = attribute('baz')

  constructor() {
    super()
    initialize(this)
  }
}

Either way there's some unwanted duplication.

One of the best things about decorators is easily avoiding duplication while also being more terse.

With decorative values, how do we apply them to accessors (and not ruin super calls)? The nice thing about accessors and legacy decorators, is that setters can be the handlers for incoming attribute strings:

class MyEl extends WebComponent() {
  this._foo = 0
  this._bar = 0.0

  @attribute
  set foo(val) { this._foo = parseInt(val) }
  get foo() { return this._foo }

  @attribute
  set bar(val) { this._bar = parseFloat(val) }
  get bar() { return this._bar }

  constructor() {
    super()
    initialize(this)
  }
}

How would we use decorative values with get/set? Maybe we can have an accessor backed by a value-decorated field:

  this._foo = attribute(0)

  set foo(val) { this._foo = parseInt(val) }
  get foo() { this._foo }

  constructor() { super(); initialize(this) }

but that has various issues (like how the decoration would map foo instead of _foo (possible but easy to break), plus now _foo is probably a second accessor layer).

Is there another way?

Your "property read/write trapping decorators" proposal would make this terse and clean, eliminating the cache variables and eliminating duplication.

Something I've seen libraries do and that I'd like to avoid, is accept handlers in decorators, which I feel makes things harder to understand and read (especially when stepping over code in devtools due to indirection in the code paths):

  @attribute((value) => {
    // ... handle the input value ...
  }) foo = 0

(which only works in Babel with lose fields, or for now TypeScript by default but soon not, and hence the whole reason to avoid decorators which will save people headaches and confusion).

The equivalent of the last example with decorative values would be:

  foo = attribute((value) => {
    // ... handle the input value ...
  }, 0)

which is still somewhat awkward, but perhaps a little better considering that foo = now comes in front, so the intent of creating a property is still clear up front. Maybe we can swap the arg order to make it better:

  foo = attribute(0, (value) => {
    // ... handle the input value ...
  })

This is reasonable. It is almost as if we're creating or instantiating an attribute.

The bottom line is decorators will do wonders to code clarity and terseness. I can't wait to use them. But for now, I agree it is better to stick to patterns that will work everywhere (Babel, TypeScript, or vanilla JS, etc) without any doubts, unlike the current decorators that might work perfectly in TypeScript only to fail utterly when a Babel user imports them (which is a nasty problem for any library to have). These problems cause time waste and headaches that library authors and end users don't want to have.


If only class fields didn't land with [[Define]] semantics, then keeping legacy decorators might at least be ok because they'd work consistently across environments...

Is there any chance implementors of major JS engines would ever consider moving class fields to [[Set]] semantics? We broke the web. Can we break it one more time to fix it?

mweststrate commented 4 years ago

@trusktr although I agree with the general sentiment, this is not the repository to discuss the future of JavaScript. That is what, in this case, https://github.com/tc39/proposal-decorators is for. So I'll gonna mark the post as off-topic

molszanski commented 4 years ago

Another strong vote against removing decorators. But if it comes to this, I think having mobx-decorators the same way we have mobx-react would be a reasonable tradeoff.

Maaartinus commented 4 years ago

https://github.com/mobxjs/mobx/issues/2325

@mweststrate

The downside of this approach is that I don't know how to do "auto" decorating with some exceptions. An additional API like makeAutoObservable(this, perMemberMap?) looks a bit ugly, although it is not too bad

I found it ugly too. Especially as there may be different ways of auto: In a class of mine all getters are computed, all fields but one are observable, but only about a half of functions are actions, others are views (in the MST sense). So I'd use {field: observable, getter: computed, function: error} as the default, which would force me to enumerate all functions, but probably save me some problems (because of function: error).

So I guess, the auto feature should be configurable, maybe like makeObservable(this, perMemberMap?, perMemberTypeMap?) with perMemberMap mapping members (field1, function2, ...) and perMemberTypeMap mapping member types ("field", "getter" and "function").

Moreover, there probably should be a global defaultPerMemberTypeMap in mobx, with the effective value given by {...defaultPerMemberTypeMap, ...perMemberTypeMap}. This would make clear how exactly "auto" works.

urugator commented 4 years ago

I would leave the makeAutoObservable to userland. The idea was not to safe keystrokes, but to provide safety (so you cannot forget observable/action etc). However it presumed that there is only opt-out behavior and that we can reliably itrospect the class, which doesn't seem to be the case (prive fields/optional fields/subclasses...?). It shouldn't be hard to come up with own implementation that suits the user's needs, while being aware of potential limitations.

I've been thinking whether the view/action problem isn't a little bit artificial: The action is mainly intended to provide batching. View with batching is fine, but we cannot use action for views because it also applies untracked. We also cannot use batch alone, because "strict mode" requires action (even though it's still mainly about batching). The only reason why action applies untracked is so it can be "safely" invoked from derivation (computed/autorun) ... however synchronously mutating state from derivation is most of the time antipattern or edge case. So potentially we could remove untracked from action (1), so it can be applied automatically to every function and forbid state mutations from derivations unless you explicitely allow that by wrapping it with effect (which applies untracked).

[1] Semantically it doesn't make sense to apply action to view, what I actually mean is to provide something that doesn't apply untracked, while still satisfying the strict mode, therefore can be automatically applied to any function. It doesn't have to be called action and it doesn't mean that action has to go away...

urugator commented 4 years ago

Actually I think it could be simplified like this: Everything stays as is. The only difference is that makeAutoObservable will use the following function instead of action:

function wrap(fn) {
  return (...args) => {
     // provide batching
     startBatch()
     // allow reads
     allowStateReadsStart()
     // !!! untracked is missing so it works with views
     // !!! allow state changes only when outside of derivation
     if (notInsideDerivation()) { 
        allowStateChangesStart()
     }
     const result = fn(...args)
     if (notInsideDerivation()) { 
        allowStateChangesEnd()
     }
     allowStateReadsEnd()
     endBatch()
     return result;
  } 
}

As a consequence if the auto decorated function mutates state, it will throw when called from derivation (computed/autorun), so you will be forced to wrap it in runInAction/action manually - either at definition:

makeAutoObservable({
  method: action, // now you can call it even from derivation 
})

or inside derivation:

autorun(() => {
 runInAction(() => store.anAutoDecoratedMethod())
})
mweststrate commented 4 years ago

@urugator yes, I start to see the light! I think you are spot on. Looking back,

  1. The main use case for the untracked part is to make sure actions called from autorun aren't tracked, allowing you to split of the observation and effect part. However, reaction solves that problem now in a more elegant way
  2. allow state changes was introduced to prevent side effects in computes etc.

I think we can indeed introduce what you are proposing, let me know if this summarizes it correctly:

MobX has a global context state (or stack) that is either NONE, UPDATING or DERIVING

method base state new state
track* NONE DERIVING
track UPDATING DERIVING
action NONE UPDATING
action DERIVING DERIVING
runInAction / allowStateChanges * UPDATING
untracked * NONE

* track is the underlying primitive of autorun / computed

  1. computed and autorun e.a. bring the state to DERIVING
  2. action brings the state to UPDATING, but only if the current state is NONE
  3. .runInAction and allowStateUpdates(true) brings the state to UPDATING regardless the current state, as escape hatch for lazy-cache-updating scenarios. We might be able to deprecate allowStateUpdates, but IIRC the false case also exists, so would have to investigate
  4. untracked brings the state back to NONE (so an action call inside a reaction inside untracked for example will allow state updates again)
  5. state updates throw in DERIVING state
  6. enforceAction keeps working as is, warning when updates happen in NONE state
  7. allowStateChangesInsideComputed can probably be deprecated? I have to search back what that one was good for again
    1. batching keeps working as is
urugator commented 4 years ago

Well basically that goes back to the initial idea - it all sort of depends on how much we are willing to change API, introduce BCs and how far we want to go with these checks.

I think you got it mostly right, but:

Semantically it's a bit weird that the action is usable as a view (when it doesn't mutate state).

Throw on write detection to enforce correct context isn't bullet proof:

computed(() => {
  // this throws so the user is forced to use `runInAction` here
  myAction();        
})

const myAction = action(() => {
  // but actually it throws here, so we can fix it here
  runInAction(() => {
     observable.set(5);
  });
})

Potentially we could restrict from which to which context the transition can occur, but then we may need to again differentiate between view/action (not sure atm).

runInAction shouldn't have a different meaning than action and allowStateChanges is just some low level thing which by itself doesn't map to any real use case, that's why I introduced that effect function, so it has semantic meaning (also it's analogous to reaction's second arg).

untracked imo shouldn't have any effect - it shouldn't (dis)allow reads/writes. It should simply disable subscriptions without changing the context. Again a low level thing that doesn't map to any real use case.

There is obserevableRequiresReaction, which basically says: Throw if you read something outside of DERIVING or UPDATING - which also means that you wouldn't be able to perform reads inside untracked (if it would change the context to NONE), which is mental, because untracked is used for reads in the first place.

EDIT: One more concern... Is it possible for useEffect to be ever invoked immediately during render (inside derivation)?

trusktr commented 4 years ago

@mweststrate Sorry, I didn't mean to hijack the issue. I was showing my custom element example hoping to discuss similar issues that would happen in MobX.

Let me ask smaller MobX-specific questions one at a time:

If MobX rolls with the approach of

class Doubler {
  value = observable(1)
  double = computed(function () {
    return this.value * 2
  })
  increment = action(function () {
    this.value++
  })
  constructor() { initializeObservables(this)}
}

then we want to extend it,

class DoubleIncrementer extends Doubler {
  increment = action(function () {
    super.increment() // ?
    super.increment() // ?
  })
}

how would we do that considering that super wouldn't work there?

In https://github.com/mobxjs/mobx/issues/1864, you said

Don't bind methods in super classes, it doesn't mean in javascript what you hope it means :-)

If MobX gives people a class-based tool, they may expect to be able to use class features like they do in plain JS without MobX.

mweststrate commented 4 years ago

MobX 6 won't go with that proposal, but the second one

On Mon, May 4, 2020 at 2:47 AM Joe Pea notifications@github.com wrote:

@mweststrate https://github.com/mweststrate Sorry, I didn't mean to hijack the issue. I was showing my custom element example hoping to discuss similar issues that would happen in MobX.

Let me ask smaller MobX-specific questions one at a time:

If MobX rolls with the approach of

class Doubler { value = observable(1) double = computed(function () { return this.value * 2 }) increment = action(function () { this.value++ }) constructor() { initializeObservables(this)} }

then we want to extend it,

class DoubleIncrementer extends Doubler { increment = action(function () { super.increment() // ? super.increment() // ? }) }

how would we do that considering that super wouldn't work there?

In #1864 https://github.com/mobxjs/mobx/issues/1864, you said

Don't bind methods in super classes, it doesn't mean in javascript what you hope it means :-)

But if MobX gives people a class-based tool, they expect to be able to use class features.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/mobxjs/mobx/issues/2325#issuecomment-623224126, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAN4NBHML3MRNXDQZHSILX3RPYNBZANCNFSM4LZQ2B5Q .

trusktr commented 4 years ago

The explicit one like initializeObservables(this, { foo: observable, bar: computed, etc })?

mweststrate commented 4 years ago

Yes

On Mon, 4 May 2020, 07:03 Joe Pea, notifications@github.com wrote:

The explicit one like initializeObservables(this, { foo: observable, bar: computed, etc })?

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/mobxjs/mobx/issues/2325#issuecomment-623274109, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAN4NBDJANONQFSOPHE3Z3LRPZLEVANCNFSM4LZQ2B5Q .

trusktr commented 4 years ago

I suppose private class fields are out of the question, as there is no way to access those dynamically from initializeObservables(this, ...). They're locked out until decorators finally do land, it seems.

mweststrate commented 4 years ago

Correct

Op zo 10 mei 2020 19:03 schreef Joe Pea notifications@github.com:

I suppose private class fields are out of the question, as there is no way to access those dynamically from initializeObservables(this, ...)

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/mobxjs/mobx/issues/2325#issuecomment-626365889, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAN4NBF4G6AZKWOGKN4MOQ3RQ3T5JANCNFSM4LZQ2B5Q .

seivan commented 4 years ago

@mweststrate What happened with this idea to tackle that?

@trusktr

mweststrate commented 4 years ago

The last comment was about JS privates, not TS privates

On Mon, 11 May 2020, 01:08 Seivan, notifications@github.com wrote:

@mweststrate https://github.com/mweststrate What happened with this idea https://github.com/mobxjs/mobx/issues/2339#issuecomment-619376221 to tackle that?

@trusktr https://github.com/trusktr

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/mobxjs/mobx/issues/2325#issuecomment-626411131, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAN4NBD6I5ARFPRIXUU2YVTRQ46WBANCNFSM4LZQ2B5Q .

Dakkers commented 4 years ago

I think the example with value: computed(function () { ... }) is much better than the initializeObservables approach - the latter reminds me of MobX-without-decorators, which led to a lot of debugging because I would forget to mark certain fields as observable/action/computed. The "auto initialize" concept is interesting (similar to autobind) but there are cases where I don't care about a field being an observable, and if it's bad for memory/CPU then I'm fine without it.

trusktr commented 4 years ago

reminds me of MobX-without-decorators

Yep, but in the constructor to overcome [[Define]] semantics of class fields.

Maybe that's best: it remains familiar and without performance cost of "auto initialize" (though an auto can be convenient in cases where the perf hit doesn't matter).

mweststrate commented 4 years ago

To clarify the original post didn't reflect anymore the new api of V6, instead we opted to implement the alternative api as found in the original post as discussed earlier in this thread. I just updated the post to better reflect the new api. So we won't be using value wrappers, but rely on makeObservable(this, map) instead.

danielwalczak commented 4 years ago

I love @robbiewxyz idea, lets simplify API, I would like to see better models API

faassen commented 4 years ago

I'm trying to understand the behavior of makeObservable and makeAutoObservable correctly so I can document them properly.

From the code I see that if you call makeObservable without arguments and no decorator members are found, it errors out. I think that's good.

I imagine this may be difficult to implement, but it would be nice if we could get some kind of warning if you forget to call makeObservable and use decorators.

What should happen if you call makeObservable with an annotations argument but there are also decorators? Should it complain?

Concerning makeAutoObservable, I see its exceptions argument is an annotation map. How do you then exclude a particular key from being made observable? I think that's done with 'false' if I read the type correctly.

What should happen if you use makeAutoObservable on a class that uses decorators?

I think it's interesting to consider that since the names and signatures of makeObservable and makeAutoObservable are quite similar what happens if someone mixes them up by accident. What are the scenarios where this would really confuse developers, and are there ways we can help?

faassen commented 4 years ago

Some comments about the 'undecorate' codemod. This turns decorator-based code into code that doesn't use decorators, and also converts code that uses 'decorate'.

Since MobX still supports decorators with makeObservable, should there be a codemod that only adds makeObservable(this) everywhere? [edit: I see that there is a keepDecorators option to the codemod, though no tests for it yet]

Should there a feature that uses makeAutoObservable when it detects this is possible?

mweststrate commented 4 years ago

Ok, pushed some changes to v6 branch, I think there is now a decent solution to the is-a-function-an-action-or-derivation dance. I introduced a separate concept of autoAction, that is to be used primarily by library functions, not directly by users, that is used by makeAutoObservable, observable(object) and the lite hooks. autoActions will on the fly choose whether they act as action (if not deriving anything already) or derivation (when inside another derivation). They batch in both cases, does only untracked in the first case, and only allows state changes in the first case as well.

If the user does updates in an autoAction that is called from a derivation, it will warn that it must be wrapped in runInAction to clarify the intend. I also updated that the predicates of when and reaction don't allow state changes by default, just like computed, for consistency.

mweststrate commented 4 years ago

@faassen

I imagine this may be difficult to implement, but it would be nice if we could get some kind of warning if you forget to call makeObservable and use decorators.

Would be awesome, but no idea how to go about that :) We could maybe at random places (e.g in observableValue) check in DEV mode if a value is a class instance with decorators, but with undecorated members. Might be a bit of a performance bummer though.

What should happen if you call makeObservable with an annotations argument but there are also decorators? Should it complain?

I think it should be ok? not sure :)

Concerning makeAutoObservable, I see its exceptions argument is an annotation map. How do you then exclude a particular key from being made observable? I think that's done with 'false' if I read the type correctly.

Correct

What should happen if you use makeAutoObservable on a class that uses decorators?

Decorate remaining members? or die? Not sure :)

I think it's interesting to consider that since the names and signatures of makeObservable and makeAutoObservable are quite similar what happens if someone mixes them up by accident. What are the scenarios where this would really confuse developers, and are there ways we can help?

No idea :)

Since MobX still supports decorators with makeObservable, should there be a codemod that only adds makeObservable(this) everywhere? [edit: I see that there is a keepDecorators option to the codemod, though no tests for it yet]

Correct. Feel free to add 😅

Should there a feature that uses makeAutoObservable when it detects this is possible?

No I don't think so. MobX is already considered to be quite magically so I think simple > short. Especially if the code is generated anyway 😄 . I think we can apply the same simple > short principle to the docs, thinking about it.

P.s. for detailed semantic questions, I recommend to ask them on the v6 PR rather than in this thread, as it might spam a lot of subscribers :)

faassen commented 4 years ago

Something that I imagine needs to be in mobx-react is a codemod that converts the observer from a decorator into a function call. I mention it here as I don't see it in notes.md yet.

danielkcz commented 4 years ago

Something that I imagine needs to be in mobx-react is a codemod that converts the observer from a decorator into a function call. I mention it here as I don't see it in notes.md yet.

Do you mean for class components? I don't think it necessary, at least not at the moment. There are no technical difficulties with supporting @observer. In fact with class components, it feels like an easier way. Chances are that people will eventually migrate toward functional components so it's probably premature.

jeremy-coleman commented 4 years ago

Just a personal observation, but i too want to use mobx without decorators. I have refactored 500-1k line class stores to use non-decorator syntax, easy peasy. But have you considered the cost of maintaining the non-decorated models compared to the inline decorator syntax? Its 100% impossible for humans to mentally keep track of 2 objects that large , so you have to comment above each class member as //action, obs, obs.ref, computed etc. So if you’re going to do that, why not just inline decorate? jsx / vue / angular / svelte etc arent es spec either , so does being es spec compliant really matter when you have to transpile anyway? I too strongly want to run mobx code without transpiling first, but when i self reflect , i cant help but think its just something i have to deal with because the alternatives are worse

faassen commented 4 years ago

Since we're changing defaults anyway, should the computedRequiresAction be the default in MobX 6? Or perhaps in the spirit of "there should be only one way to do it" remove it entirely as the docs say (somewhat unclear to me): "Though this restriction is confusing and contradictory Computeds can be altered to work in a direct access manner with some of the following methods..."

elektronik2k5 commented 4 years ago

I wouldn't remove computedRequiresAction entirely cause some people like it. I definitely support making it the default though!

johnhamm commented 4 years ago

I agree with what seems like the majority sentiment here - to keep decorators. There hasn't been any stated legitimate reason to remove them honestly. This whole "conundrum" just seems odd to me.

danielkcz commented 4 years ago

@johnhamm Have you actually read Goals in the first comment? There is a bunch of legitimate reasons for such a decision.

olee commented 4 years ago

Also, afaik decorator support will not be removed. Instead it's just that decorators are not the primary implementation method and instead in the new implementation they are just providing metadata which the make-observable function can use to know how to handle which field.

@mweststrate does the new v6 branch already contain decorator support? Or is it for now code-configuration only? If not, did you already decide whether decorator support will be a separate package & when it will be added?