-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Can we improve the API in such a way that people will better understand what MobX does? #1316
Comments
For reference, current API:
|
Maybe this makes everything a bit more explicit? Here a very first iteration of a draft proposal Proposal
TL;DR
Things dropped
|
Sounds pretty good. You didn't specify what the functions / annotations do exactly: Does @observable.deep and shallow (and @observable({depth: "deep"})) throw if passed a class instance or a primitive / string? I think they should. And why does Also, how would inner modifiers work here? E.g. you have an @observable.deep property but somewhere in the sub objects you want to have a shallow / ref property? |
Wrote it in the other thread, but just for reference I'll add it here too I suggest that any proposal should involve rewriting https://mobx.js.org/getting-started.html and seeing if it still looks like a good introduction. |
Yes this is quite confusing ... basically there are 2 ways of how to look at the "depth" option in case of observable containers:
"depth" option doesn't work well with type specific factories and causes confusion because of this "depth" shifting. Current "optionless" API doesn't have this issue.
Modifiers only say what to do with non-observables, so if you put something "shallow" into something "deep" it stays shallow, because it's already observable. |
I think this is mainly a documentation issue. All tutorials and examples show this: @observable prop = val;
//or
observable(o); Why current doc doesn't emphasize the same things as the proposal (references/specific types/etc)? We will just replace |
For (1) I have a solution: separate functions for isObservable instead of overloads i.e. For (3) I see that as a feature, not a bug. Its "transparent reactive programming" after all. Aren't proxies going to help make it even more transparent, btw? 😀 |
Good points being made here :). Pretty hard to beat the current api, but there was given a lot of thoughts to that so that makes sense. Maybe the changes should be smaller. Alternative idea. Agreeing here with @urugator that
|
I'm look through this again later, but a few thoughts:
I love the current API, but given how I tend to use MobX 'shallow by default' makes a lot of sense. |
Imho
Agree
Agree (in case ref/shallow is default)
Really don't like the idea of strict mode specific behavior. Maybe we should drop the @observable
@deepObservable
@shallowObservable
observableArray() // or just array
deepObservableArray() // or just deepArray
No, because we still need Additional suggestions:
Also consider that users are supposed to modify existing observables instead of replacing them, therefore the autoconversion actually shouldn't occur that often! |
Nobody read my getting-started point 😢 But anyways if you change the default from deep to shallow, and then you read the "10 minute introduction to mobx", you just broke I think the whole premise that there is somehow a problem with defaulting to deep observables is completely wrong. I really don't see it. Its the exact right tool for a new user to get started with. Then as they learn they can be more explicit in what they mean. There are problems with that approach, but have you considered the problems that the alternative will cause? I think they're even bigger. There could be a documentation section "deep dive into ref/collection/deep" where the mechanics of each are explained (ref will watch assignments, but not content; collections will convert collections, but not individual elements; deep goes as deep as it can in doing the above) |
I would say it's exactly the opposite, because there is no depth (at least by default). Anything which should be observable has to be made explicitely observable by user, not inside some setter behind his back, with additional side effects (cloning - lossing original reference, changing type or ignoring non-convertibles).
Why? you will just set/push @phiresky's question about inner modifiers also proves how unintuitive the autoconversion can be.
I tried to outline some in previous post, but feel free to elaborate.
The thing is... should user need to read "deep dive section" to understand what his code actually does? For clarification no autoconversion doesn't mean there won't be recursive factories, it only means that observables must be created explicitely (not by assigment/push/set etc). |
@urugator so its no longer transparent reactive programming. Its no longer enough to add decorators to your original class, you have to change the code too. I think this will turn many people away. People went with angular due to the promise of the plain Oh, and if that todo has some nested list, I assume you will also do Not to mention how this is all super error prone. If you forget to deeply apply observable on something, the result is that your UI wont update properly, and you might not even have any idea why! You'll have to chase down all dependant values and see if you're always replacing them in such a way that the replacement is observable. Hello new "stale UIs" reputation. Sorry, I haven't changed my mind. The deep default (while somewhat magical and with some small caveats that are hard to understand) still has a better tradeoff value IMO. |
Well why not do that then? If you are using the automatic |
don't know what that means, but autoconversion seems very opaque to me (it's an invisible side effect of different operation)
Yes, as I've stated the code is more verbose, because there is less magic.
Autoconversion creates a false illusion that you can work with original plain objects (or graphs), while it's not actually true (it was in v2 and only in case of objects and it was source of other problems too).
Why would you set observable to non observable and then create an observable based on it? (Maybe you're missing // Create new todo from scratch (I suppose that making things observable when they're created should be the most common scenario)
observable({
whatever: 5,
nestedList: observable([]); // or just [] see following
});
// Create todo from existing object (recursive factory - previous post - last paragraph)
observable(todo, { recursive: true }); // could be default or syntax can differ, that's not the point
Yes user may forgot to make something observable and it will definitely happen. What seems positive to me is that it's basically the only mistake he can make, it's easy to explain and understand.
I think it would make some scenarios unnecessary complicated (converting nested structures with some inherently uncovertible items). Single object is easy to handle, but "let it be if you can't" seems like more useful approach when converting whole object tree. But the idea of "safe conversions" is interesting. Maybe we could have something like |
If we are making breaking changes, I think throwing on unconvertables could be default when doing deep conversion. If you want it to stop the conversion, nest an explicit Wouldn't this only break existing code that is already (subtly) broken? |
@spion While it's possible to convert deeply nested structures, it's impossible to cofigure it's specific parts, so requiring this configuration (ref/shallow) seems impractical to me... const state = observable(window.__STATE__);
state.something.somewhere.currentElement = Document.createElement("a"); // throws? not cool
// I would have to convert the whole tree by hand, because some part must be configured with ref/shallow... |
The idea is, since you are using a deep automatic observable, those work with plain, known objects and collections only. If you want something else, you use explicit annotations i.e. you use a ref-box to wrap the element. ( Unlike the reverse idea, forgetting to use the proper wrapper throws rather than being incorrect and causing stale value bugs elsewhere. |
Thanks for your input guys! I really like the discussion that is going on here. It really feels like nicely highlighting the cost and consequence of either direction @spion yes I will definitely consider the impact on the 10 minute introduction :) Also check #1321, I incorporated a few changes discussed here that I felt comfortable about ( |
As presented, it's not always possibe to use annotation/modifier without changing the architecture and reimplementing the conversion by hand.
Not a solution, it introduces another unnecessary observable, but more importantly the nonconvertible can be present at the moment you passed it to const js = Mobx.toJS(original);
const state = observable(js); // throws
// currently the conversion rules for toJS and observable are the same but inverted I think I understand where you're heading. You basically want to enforce the rules in way, that the side effects of autoconversion won't be noticable, so we could preserve the illusion of working with normal objects, while removing or minimalizing the possibility of getting into trouble. |
@urugator then I suggest we
Then make the same list for the opposite approach similarly. This should result with enough info to make a good decision. Off the top of my head... (will add examples later as they come) deep problems
shallow problems
|
@spion shallow/deep by default and no-autoconversion are two seperate discussions.
I am not sure what you mean, because this is not the case since MobX 3.0, everything is cloned before converted. const o = observable({ a: null });
const a = {};
// current behavior
o.a = a;
o.a !== a; // eh? (actual behavior depends on type of "a", whether "a" is already observable, on modifiers applied to "o.a" and where "o.a" comes from)
// without autoconversion
o.a = observable(a);
o.a !== a // obvious
Solvable by:
const o = observable({});
o.a = 5; // ok, primitives are ignored
o.a = observable({}) // ok, value is observable
o.a = {}; // throws "Attempt to assign non-observable value to observable field, if this is an intention please wrap the value in Mobx.nonobservable(value)"
o.a = nonObservable({}); // ok, the intention is explicitely stated (it's like dangerouslySetInnerHTML), the value is unwraped during assigment
const o = observable({});
o.a = {}; // throws
Mobx.unsafe(() => {
o.a = {}; // ok
})
const o = observable({ a: [{}] });
isObservable(o.a[0]); // true
const o = observable({ a: [{}] }, { recursive: false });
isObservable(o.a[0]); // false
Fixed by // default "strict" converter throws on non-convertibles
observable(new Something()) // throws
observable(new Something(), { recursive: false }); // throws
observable({ a: new Something() }) // throws
observable({ a: new Something() }, { recursive: false }); // ok
// "tolerant" converter leaves non-convertibles unconverted
observable({ a: new Something() }, { converter: "tolerant" }); // ok
// "forceful" converter treats non-convertibles as plain objects (is there a better term?)
observable({ a: new Something() }, { converter: "forceful" }); // ok
// own converter
const myConverter = any => any instanceof Date ? observableDate(any) : observable(any);
observable({ a: new Date() }, { converter: myConverter }); Originally I went for
To get started user only needs
It's not like that even now, only seemingly, causing a lot of issues.
The whole API could look like this: observable(value, { recursive: true, converter: "strict" }) // these are defaults
// "Modifiers"
nonObservable(value)
// or/and
unsafe(operation)
// Type specific factories
object/array/map/set/box(value) // no options, non-recursive
// Decorators
@observable // no options, no modifiers, simply makes property observable (same as current @observable.ref) |
I'm saying that may have been a premature "fix". What was the un-transparent behaviour of the modified object? It wasn't clear from the issue. Maybe it was possible to make the modification effectively invisible?
It is like that, for me, and its not causing any issues whatsoever. The only serious issue has been array concatenation, and i consider this a problem with the standard library guessing too much (and lodash et al following its example).
I count 7 or 8 methods there. Also, flags values count as additional methods as you have to learn the meaning of each. Close enough! The marketing issue is real, by the way. I constantly sell mobx to people by saying: go to https://mobx.js.org/getting-started.html and see how learning 3 decorators gives you more than Redux ever did (and its true thanks to the superpowers of computeds). |
Close to what? Is there a target? |
Thanks for the input so far guys! Some personal conclusions so far
Proposed changesBased on that, some proposals to sharpen the API awareness, difference between properties and values, ec, I feel quite confident on making these changes:
A bit more radical changes which make the api more consistent but upgrading more complex
Summarized:
Some remaining questions
Reduce the need for decoratorsDecorators don't seem to stabilize in the standard, so I think @urugator 's proposal to introduce our own |
I used term
Imho it's impossible to communicate this clearly with autoconversion, because you can never tell whether you're assigning to something observable or not. This is because the "observability" is defined on absolutely different place than actually applied. Without autoconversion, there would be 2 additional @observable todos = observable([]); // here
this.todos.push(observable({ // here
task: task,
completed: false,
assignee: null
})); That's all. 20 chars is the difference between "quick onboard + selling point" and "simpler api, simpler impl, zero confusion" (if we would use "getting-started" as metrics, which is silly imo)
I don't understand the "strictness" of @observable.deep something;
@action setSomething(any) {
// so I want to turn "any" into observable
this.something = any; // nope throws on any
// wtf workaround
this.something = { temporal: any };
this.something = this.something.temporal;
} |
@urugator I would personally probably never gotten started with MobX if I saw that every single record needed to be wrapped with some function. Here are a couple of questions for you:
I don't see the problem. Setters can ignore the reference being set and create their own, its a JS feature. Methods like push can ignore the ref being pushed and create their own. Instead of helping, I see this creating incredible amounts of useless boilerplate with observable wrappers flying around everywhere. I would be more interested in an experiment to bring mutation on the right-hand side back, then figure out why its not completely transparent to everything else. |
Throws "Attempt to assign non-observable value to observable field, if this is an intention please wrap the value in Mobx.notObservable(value)" or something like that as already mentioned.
Excellent example! Do you know what is the current behavior? Exactly! I am not sure either and I consider myself an advanced user! So I had to try it out... you may be quite suprised with the results: console.log("deep");
const deep = Mobx.observable.array([{}, {}]);
let result = deep.map(x => ({a: x}));
console.log(Mobx.isObservable(result)); // false (array)
console.log(Mobx.isObservable(result[0])); // false (array's elements)
console.log(Mobx.isObservable(result[0].a)); // true (cloned original {})
// shallow
console.log("shallow");
const shallow = Mobx.observable.shallowArray([{}, {}]);
result = shallow.map(x => ({a: x}));
console.log(Mobx.isObservable(result)); // false (array)
console.log(Mobx.isObservable(result[0])); // false (array's elements)
console.log(Mobx.isObservable(result[0].a)); // false (original {} ref) It doesn't convert! Is it a bug? Is it an intention? I don't think it matters, it shows how twisted, unpredictable, non-trasparent the autoconversion idea is ... emotions and exclamations aside: this.foo = arr.map(x => ({a: x})); // throws to avoid staleness
// =>
this.foo = observable(arr.map(x => ({a: x}))); // no need to make individual things observable before they actually became part of state again Consider that
Sure, but don't call these "plain objects/array" if they don't work like that. |
Your unpredictability argument is my transparency argument. I read it as
Regarding plain objects and arrays, Honestly, I'm not happy about the cloning change, but I can live with some impedance mismatch so long as its possible to write code that results with the same result with or without decorators. I will acknowledge that at this point there are no serious technical flaws in your proposal that I can see. My only remaining concerns are aesthetic - a strong preference for errors where the software failed to convert/understand something over errors instructing the human to do something that the computer could've done by itself; a strong dislike of verbose noise in code. edit: oh, and whether its possible to set up all the error / wrapping / conversion cases in a way that is consistent and makes sense. p.p.s. I still find |
But you don't know for sure whether the conversion actually kicks in, it doesn't work like this everytime, that's why it's not transparent (and also because it tries to look like a simple assigment while it is not). As stated earlier, the behavior depends on configuration, which is placed on some absolutely urelated place.
But that's the thing, the computer can't do it itself or to be precise JS can't. The situation would be quite different if it would be technically possible to "enhance" existing objects. Actually it was partially the case before Mobx V3 - the objects were converted in-place, while "arrays" were considered a special case (due to a technical limitations). |
Sounds to me like the solution is to control observability by type, not by modifiers e.g. you should be able to declare that "immutablejs objects are always considered refs". Then you can autoconvert built-ins, not autoconvert objects and classes that have at least one observable decorator, treat above known types as specified and throw on everything else. The "shallow" concept also goes away. To be honest, I'm not worried so much about the "assign value to observable, modify that value and expect reactions to run" scenario. I think "expect observable value to also change" is sufficient compromise in this case, and you can get that with proxies (since they're not clones). Mostly when this usually happens in the wild, you are initialising an observable property with a new value coming from a scoped variable that goes away soon, so the observable property is already dirty within that action. |
Just came across this and remembered |
I come late to the discussion and I couldn't read everything, but these are just my 2 cents: I am looking forward to a new version for big features like proxy support, support for native sets, and so on, not really for API changes. The API can always be improved, but in its current state is not in a bad shape at all, and the confusion it can generate usually goes away pretty fast after a little experience with it. these two alone will need to be fixed in many places in our codebase) I understand that the API needs to get reviewed from time to time to not become obsolete, but there were already quite some changes in version 3, I wouldn't mind waiting some future versions ( > 4 )for the reworkings proposed here. Regarding the changes itself, one compromise would be to keep the current API but add tslint rules (or equivalent for flow/js) that would generate warnings when used in the improper way (for example when using @observable with a non-primitive ). The outcome would be to preserve the simplicity, non-verbosity of the current API but still help newcomers to understand what's going on and avoid mistakes. so tl;dr;: split the feature changes from the api changes (or postpone them directly, and switch to lint warnings) |
@MastroLindus Don't you think that the need to use some additional tools (flow/lint) is yet another barrier to overcome before getting started? |
@urugator I see your point, and you might be right, however: 2)I tend to agree that a clear, understandable API shouldn't be replaced simply by some external tools, but as with many other APIs for complex functionality, the sweet spot between being concise and verbose, deceivingly simple or overwhelmingly complex is really hard to find. Given that, many arguments in this thread make sense, and if the API changes, I will simply roll with it :) |
I hope not, this kind of reasoning actually frightens me tbh: These tools are workarounds, they are used due to a technical limitations or specific needs, people shouldn't use them, design things around them or rely on them, unless it makes their product cheaper. But I think you made a good point that radical or frequent API changes can undermine user's confidence in Mobx. |
In short, I agree tht transpilers are used because of technical reasons(even if they do provide quite some benefits), but linters and ESPECIALLY compilers are immensely useful since humans can be as good as you want but in specific tasks (identify errors, bugs, keeping order during huge refactorings etc) they will never be as exact and precise as programs designed just to do that. They lower the cognitive effort that a programmer (and especially a team of programmers together) need to use in order to produce, verify, and maintain reliable code. And I guess that going forward is inevitable that tools&software will do more and more of what before was done manually. Anyway, I think we got a bit too far in this, and I apologize for that. I would really happy to continue discussing those points with you elsewhere, as I am afraid it would be off topic here and not really useful for mobx :) |
Hello, I would rather improve the docs. Focus them more on use cases and investigate what people ask themselves when they start using mobx. It's hard to answer your original question for people who are already seasoned mobx developers, because we aren't "surprised" anymore. Well yes, one thing that gets me from time to time. If I forget to use |
Thinking while working. I do have something in mind now. Could we do something about the need to wrap callbacks in "async actions"? I know there are babel plugins but, it would be nice to have something like this directly in the API. People wouldn't have to wonder why using |
@AoDev are you aware of asyncAction? (it's mentioned at the end of the docs...) |
@urugator I was not aware of it, thanks for the link. :) But it seems to work only with generators. While it works, it's still another variant of "how to write async actions because you can't just use action". I would rephrase my question to the following: is it possible to have a simple decorator that would work with any syntax like |
Last time I glanced at things, its not technically possible due to bad interactions in the design decisions of promises and async/await. |
Well the syntax offers the same comfort as async method() {
const a = await fetch(this.url);
return await this.asyncAction(); // obviously you don't have to await/yield here
}
// ==>
@asyncAction
*method() {
const a = yield fetch(this.url);
return yield this.asyncAction();
} We could also come up with a function which wraps standard promise and returns own "thenable": return Mobx.Promise(fetch(url))
.then(data => {
/* runs in action*/
return fetch(url); // wraps the result
)
.then(result => { /* runs in action*/ }) |
Afaik a custom promise wont solve the issue, because of two issues with the design of ES6 promises and async/await:
For a demo, try this code:
The output is:
i.e. its impossible to "trap" the code after the await in, say, |
I wasn't suggesting to use that custom promise inside async ...obviously you can await it but the code inbetween awaits won't run inside action... (the code in promise should..) |
Beyond that, closing this issue, as the new api can now be beta tested!
Migration guide: https://github.com/mobxjs/mobx/wiki/Migrating-from-mobx-3-to-mobx-4 |
Thanks for all the input! And if things seems to be trouble some feel free to reopen. The discussion kinda took place into two different issues, so I didn't respond to all issues, but rest assured I did read all of them and used them as input for the new api design :) |
Can we improve the API in such a way that people will better understand what MobX does?
The discussion was initiated in the 4.0 roadmap discussion here: #1076 (comment)
For an extensive background (discussing the current API design): #649
The current api to create observables is generally well appreciated. However, currently I have one beef with it:
It doesn't help people, especially not too experienced programmers, on what MobX does.
Especially the conceptual difference between a value and a property is particulary confusing.
I think this is partially caused by the fact that
@observable anything
generally just works, until it doesnt :)Some typical examples:
@observable buf = new Buffer();
.isObservable(this.buf)
returnsfalse
. Which is correct. Should have been:isObservable(this, "buf")
was intended.observable(thing)
will makething
observable, unless it can't, in which case it will create box around the thing rather then enhancing thing.this.items = this.items.filter(...)
orthis.items.replace(this.items.filter(...))
. With@observable items = []
both will work, but it would be nice if people grabbed the difference.The text was updated successfully, but these errors were encountered: