mobxjs / mobx

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

[breaking change] Get rid of field initializers (and legacy decorators) #2288

Closed Kukkimonsuta closed 4 years ago

Kukkimonsuta commented 4 years ago

Enabling useDefineForClassFields in TypeScript will prevent decorators from working (both transpiled and using mobx.decorate).

This flag will be enabled by default for ESNext once class fields go stage 4: https://github.com/microsoft/TypeScript/issues/34787

Possibly related to https://github.com/mobxjs/mobx/issues/1969

Intended outcome:

autorun using objectState is executed upon clicking on "increment" button. autorun using classState is executed upon clicking on "increment" button. autorun using classStateNoDecorator is executed upon clicking on "increment" button.

Actual outcome:

autorun using objectState is executed upon clicking on "increment" button. ⛔️ autorun using classState is NOT executed upon clicking on "increment" button. ⛔️ autorun using classStateNoDecorator is NOT executed upon clicking on "increment" button.

How to reproduce the issue:

https://codesandbox.io/s/fragrant-frog-x2487

Versions

TypeScript 3.7+ MobX - all tested versions (4.x, 5.x)

mweststrate commented 4 years ago

@xaviergonz yeah, it is more babel I am worried about :) iirc babel-decorators-legacy is not compatible with modern field initializers, but above mentioned plugin (hopefully) is. So my goal is to have a simple decorator implementation that is compatible with babel, TS and define semantics. But which combination actually works has to be investigated still. Just want to figure out the general direction first

spion commented 4 years ago

I only used Reflect.defineMetadata out of habit! You could use any old Map / WeakMap to remember that metadata if we don't want to depend on that compiler mode or the reflect-metadata module :grinning:

Either way, I don't think the emitDecoratorMetadata mode is required. Using the reflect-metadata module is less invasive, but also optional - although it does take care of poly filling Map/Set as appropriate.

kubk commented 4 years ago

@mweststrate Am I correct that your sixth example will not work because primitives should be wrapped in observable.box? https://github.com/mobxjs/mobx/issues/2288#issuecomment-593008340 This is how your example will look like with observable.box:

class Todo {
  width = observable.box(20);
  height = observable.box(10);

  surface = computed(() => {
    this.height.get() * this.width.get() * this.utility();
  })

  double = action(() => {
    this.width.set(this.width.get() * 2);
  })

  utility() {
    return 1;
  }
}

This example is much more verbose. Or there are plans to get rid of observable.box?

mweststrate commented 4 years ago

the idea is that initializeobservables(this) could automatically unbox the observable boxes (observable objects already use an observable box per property behind the scenes). So techincally it would be pretty straight forward to have it behave the same: width = observable(20) creates an observable.box, then initializeObservables generates a getter and setter for width that redirect to the box's getter and setter.

On Wed, Mar 4, 2020 at 12:21 PM Egor Gorbachev notifications@github.com wrote:

@mweststrate https://github.com/mweststrate Am I correct that your sixth example will not work because primitives should be wrapped in observable.box? #2288 (comment) https://github.com/mobxjs/mobx/issues/2288#issuecomment-593008340 This is how your example will look like with observable.box:

class Todo { width = observable.box(20); height = observable.box(10);

constructor() { initializeObservables(this) }

surface = computed(() => { this.height.get() this.width.get() this.utility(); })

double = action(() => { this.width.set(this.width.get() * 2); })

utility() { return 1; } }

Or there are plans to get rid of observable.box?

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/mobxjs/mobx/issues/2288?email_source=notifications&email_token=AAN4NBGWOADMCRQZFAVAG33RFZBUVA5CNFSM4KV4PIV2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOENXS4QY#issuecomment-594488899, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAN4NBC7ANMFVJ7KOLP4JQ3RFZBUVANCNFSM4KV4PIVQ .

mweststrate commented 4 years ago

So I am still limping on two different lines of thoughts here,

  1. go for decorators, but simplify (see comment) and make a constructor (or class decorator mandatory)
  2. drop decorators entirely and be done with that now and forever (a.k.a. until standardized at least) and have wrappers used everywhere. The 'unboxing' in the constructor could than even be optional; without unboxing everything still works but .get() has to be called explicitly like in @kubk example. This is nice for purist caring about side-effectfull property reads / writes, but the main benefit is that it reduces the many ways of achieving the same thing in MobX, making learning easier, where 'unboxing' is just an incremental convenience step, rather than a totally different way of writing things, like extendObservable vs. decorators vs. decorate.

Edit: Some further realizations: option 2 doesn't require any transpilation at all in modern JS. option 1 shares methods by default on the prototype (pro!) but 2 binds them by default (a different pro) (non-bound shared methods is still possible, by doing e.g. Class.prototype.method = action(Class.prototype.method) after class definition)

Edit: hypothesis: initializers that refer each other need to make sure to read 'boxed'. (but at least that would result in tracked reads in contrast to when using decorators, which might be even more confusing. Anyway, lets make sure we cover that in tests in any case)

Edit: created a poll because the collective twitter wisdom can always be trusted: https://twitter.com/mweststrate/status/1235227165072822272

wickedev commented 4 years ago

For reference, this is how Ember does it (since basically two month, I guess we have been some inspiration :)):

class Person {
  @tracked firstName;
  @tracked lastName;
  @tracked age;
  @tracked country;

  constructor({ firstName, lastName, age, country }) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.age = age;
    this.country = country;
  }
class Counter {
  @observable declare count: number;

  constructor({ count }: Counter) {
    this.count = count;
  }
}

The declare keyword is not available in codesandbox, but it is available in the actual typescript compiler. Of course, if you use the declare keyword, it becomes more verbose and can't use field initializers, but giving the initial value in the constructor can keep the code clean even when initializing the store for testing or SSR. What do you think?

https://github.com/wickedev/mobx-poc

urugator commented 4 years ago

reduces the many ways of achieving the same thing in MobX

I dunno... do we create observable objects like this:

const o = {
  width: observable.box(20),
  height: observable.box(10),
  surface: computed(() => {
    this.height * this.width * this.utility();
  }),
  double = action(() => {
    this.width = this.width * 2;
  }),
};
initializeObservables(o);

How do I create an actual box?

class {
  width = box(box(20));
  computed = computed(computed(() => {}));
  constructor() {
     initializeObservables(this);
  }
}

How do I create a ref to box?

class {
  width = ignoreMe(box(20));
  constructor() {
     initializeObservables(this);
  }
}

What about other "boxes": array/map/set (or other classes?)

class {
  map: observable.map(); // is this.map observable?
  map: observable.box(observable.map()); // or do i need "extra" box
  constructor() {
     initializeObservables(this);
  }
}

If we want to simplify things I still vote for:

// Creating object:
const o = observable(object, decorators)

// Extending object
o.newProp1 = "a";
extendObservable(object, decorators)

// Extending instance
class {
  constructor() {
    extendObservable(this, decorators)
  } 
}
// In all cases the default behavior is same:
// fn => action (bound)
// get => computed
// other => observable

extendObservable can additionally look for decorators on prototype if one prefers an actual @decorator or decorate

mweststrate commented 4 years ago

Have to go atm, so quick reply without looking into individual cases, but we'd always box, which would be basically no different from now where @observable x = {}, just like x = observable({}) would both create an observable object, and an observable property that holds that object

On Wed, Mar 4, 2020 at 6:25 PM urugator notifications@github.com wrote:

reduces the many ways of achieving the same thing in MobX

I dunno... do we create observable objects like this:

const o = { width: observable.box(20), height: observable.box(10), surface: computed(() => { this.height this.width this.utility(); }), double = action(() => { this.width = this.width * 2; }), };initializeObservables(o);

How do I create an actual box? Like this?

class { width = box(box(20)); computed = computed(computed(() => {})); constructor() { initializeObservables(this); } }

How do I create a ref to box?

class { width = ignoreMe(box(20)); constructor() { initializeObservables(this); } }

What about other "boxes": array/map/set

class { map: observable.map(); // is this.map observable? map: observable.box(observable.map()); // or do i need "extra" box constructor() { initializeObservables(this); } }


If we want to simplify things I still vote for:

// Creating object:const o = observable(object, decorators) // Extending objecto.newProp1 = "a";extendObservable(object, decorators) // "Extending" classclass { constructor() { extendObservable(this, decorators) }
}// In all cases the behavior is same:// fn => action// get => computed// other => observable

extendObservable can additionally look for decorators on prototype if one prefers an actual @decorators or decorate

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/mobxjs/mobx/issues/2288?email_source=notifications&email_token=AAN4NBBMRZND4QHYGGABKOTRF2MJ5A5CNFSM4KV4PIV2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOENZNNEQ#issuecomment-594728594, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAN4NBD4FGKHHHKE67L27ZLRF2MJ5ANCNFSM4KV4PIVQ .

elektronik2k5 commented 4 years ago

@mweststrate Sounds like a show stopper for CRA based apps that cannot (officially) configure Babel plugins. Feels like extra hurdle people might be reluctant to accept.

What about using Babel macros? I have no idea whether the necessary transformations are possible in macros, but it is worth checking.

mweststrate commented 4 years ago

@urugator care to share the babel transform?

I dunno... do we create observable objects like this:

No, we can keep that as is / support both. The main goal is to be able to convert classes while keeping 'decorators' co-located

How do I create an actual box? How do I create a ref to box?

I don't think there any actual use case for that, but if there is, an assignment in the constructor or double wrapping would work in deed

map: observable.map(); // is this.map observable?

yes, like with @observable, you will get automatically a box for the property that holds the ref to the observable collection

@wickedev as far as I can see that still won't work without another abstraction that interprets the decorators; with define semantics the local fields still shadow the decorators by default playground

@urugator

Eg. calling action from computed or reaction is detectable and both can throw.

It is an interesting thought, but I feel like we have been kept piling rules and ways to make exceptions to rules on top of each other (e.g. calling actions from reaction is quite valid, and lazy loading patterns where a computed might trick a side effect action if called for the first time, like in mobx-utils, or making props observable or stores mobx-react(-lite) observable has resulted in a lot of _unblessed api's, which are ok for building libs, but which we don't want to encourage to be used, as it is too easy too overuse them without grokking the mental model of mobx fully.

In hindsight I'd love to rely on naming convention, e.g. only actX... for auto conersion, but that would create impossible migration paths at this point :) I don't think this proposal in its current shape removes the need for those _unblessed api's at this moment, but at least hopefully prevents in making the hole deeper.

I think in the end if we don't have enough info to always make the correct choice, opt-in is better than opt-out. That being said, feel free to flesh out a bit how that api would look like? In short, I'd be interested how we can design a class that has members that should not be observables, and methods that should not be actions. I think in any case it would be nice to add an initializeObservables(this, 'auto') for classes that don't need exceptions.

Also, better name for initializeObservables is welcome. Was thinking about unbox, or mobxInit, but that might be too abstract :)

urugator commented 4 years ago

flesh out a bit how that api would look like?

observable (fn + decorator) Like now, applied by default to fields.

computed (fn + decorator) Like now, applied by default to getters.

action (fn + decorator) Applied by default to functions. Cannot be called from derivations (computed/reaction(first arg)/autorun) (unless inside effect). Throws: Calling action from computed/reaction/observer is forbidden. If this is an utility function that doesn't mutate state please use ingore. Mutating state during derivation can lead to inifinite cycles, can break batching and can lead to unexpected behavior. Consider refactoring to computed or use effect, see docs, blah blah.

effect (fn + decorator) Like action, but can be called from derivations, allows mutating observables inside computed/reaction/autorun.

ignore (decorator) For utility methods and fields that shouldn't be observable for whatever reason. Also makes sure that if you (repeatedly) call extendObservable on already observable object it won't attempt to make the field observable.

Additionally: Mutating state from anywhere outside of action/effect is not allowed. Reading observables from anywhere outside of reaction/action/effect is not allowed.

Therefore: We don't need specific view decorator (ignore by itself doesn't allow mutations). We can ditch enforceAction/requireReaction/requireObservable/etc, these are always on and cover your back. We can ditch _allowSomething as they can be replaced by effect (maybe not always not sure..).

care to share the babel transform?

Sure, but keep in mind it's the first thing I tried with babel and it's very unfinished. Personally I don't need it for anything, just wanted to try something new: https://gist.github.com/urugator/ffc92893fafd70d6e52019f38594ece9

JohnNilsson commented 4 years ago

If the decorator argument was a function (or visitor) it would allow default policies to be decided per project. Could even implement things like naming convention based policies. And would allow such things to be factored out into independent npm packages. Edit: To clarify

import customPolicy from './mobx-policy';
const customInitializeObservables = (self, options) => initializeObservables(self, customPolicy, options);
//...
  customInitializeObservables(this,{...})
Maaartinus commented 4 years ago

I think in any case it would be nice to add an initializeObservables(this, 'auto') for classes that don't need exceptions.

And maybe something like initializeObservables(this, 'auto', {someField: 'ignore', someAction: 'action'}) when there are exceptions.

Using initializeObservables(this, 'auto', {ignore: ['someField', 'someField1'], action: ['someAction', 'someAction2']}) might be better (more compact, but possibly more error-prone).

I'm unsure what's more common, actions or views. Maybe there should be auto-action and auto-view instead.

For naming conventions, initializeObservables(this, 'naming') could be used; possibly with added exceptions as above.

Also, better name for initializeObservables is welcome. Was thinking about unbox, or mobxInit, but that might be too abstract :)

As it's about the only user-facing thing mobx does, mobxInit is IMHO not bad. Maybe mobxInitObject as it deals with a whole object containing observables and other stuff. I don't understand unbox.

mweststrate commented 4 years ago

Closing this one now in favor of #2325, to avoid that the discussion happens in two places.

sonhanguyen commented 4 years ago
const State = decorate({
  value: observable,
})(class S<T> {
  value: T = 0
})

const s: S<number> = new S<number>(...) // : S<number> won't work

@xaviergonz this seems to work (no inference but not terribly annoying)

https://www.typescriptlang.org/play/?ssl=7&ssc=24&pln=7&pc=30#code/MYewdgzgLgBAJgU1AJwIZQQQgFwwDwAqAfABQD6uqYAngJQwC8RM5uB9TMBAUN8ADaoIEGAGUoIZAkLMA3txgwAbqn4BXBIxgAGbgF9eoSLGiTNDeEknppUagAcEIAGZiJU0vMUr1CXAHIQACMIBGQVIP4EfwAafVoScTNaQ3BoGFQtMAQAdxhTKTwwNQBbILDSWiA