Open benjamingr opened 4 years ago
I wonder how you would accomplish that with this library. You would need some analog to react's setState / useState so that updates to a component's state happened in a way the framework would be aware of.
That kind of combats the entire idea of "just JavaScript" in the sense that state is no longer just variables in a function. I'm not asserting if that's good / bad or right / wrong. Having a mechanism to say "hey I have these values only state and if they change you should refresh" might be a good idea.
Maybe this functionally could or should be some kind of a plugin instead of baked into the core framework. Then developers could opt into that kind of behavior.
@JasonMatthewsDev have you seen how reactivity systems like Vue and MobX works?
Basically:
o
and a proxy/getter.Here is a tiny implementation from a while ago and here is how vue does it. It's not a new idea.
I think it would be a lot better to have as a core part of the framework (take MobX or another reactivity library in) in order to create ergonomic UX.
@benjamingr thanks. I'm familiar with those patterns. There's just something so pure about a vanilla function with vanilla js variables manipulation that I really like.
I suppose even if it were made part of the core framework, the developer could still opt in to using it or not.
There's just something so pure about a vanilla function with vanilla js variables manipulation that I really like.
The reason I like those patterns is precisely because it's just vanilla code - you end up writing JavaScript and the reactivity is from the proxies. The code consuming your code doesn't care about the fact you're reactive :]
There's just something so pure about a vanilla function with vanilla js variables manipulation that I really like.
The reason I like those patterns is precisely because it's just vanilla code - you end up writing JavaScript and the reactivity is from the proxies. The code consuming your code doesn't care about the fact you're reactive :]
Yes, in the sense that you can just get and set properties on your reactive object right? But no in the sense that I can't just declare a new variable in my function and get the same reactivity. So I guess what I'm saying is that it still adds the, albeit small, burden of additional domain knowledge.
There's already the burden of having to explicitly refresh a component so I'm not saying this is a reason against doing it, just having a dialog.
I considered using a proxy-based API, but I typically try to avoid proxies because I dislike metaprogramming, and didn’t see what the clear benefits would be. For instance, I am puzzled by people who say this.refresh
will cause bugs because it’s literally just a method call and if you forget to call it, you will immediately notice in the course of development.
The concrete reasons why I think using local variables and this.refresh
is better than proxies is as follows:
State updates often include changes to multiple properties and if we were using a proxy, it’s not clear how to coalesce assignments so that only one update were triggered for a batch of changes. You could batch them by some unit of time but this felt less executionally transparent than letting the user modify the local state of the component how they liked and calling refresh when they were ready.
Because Crank uses local variables for state, we can actually blend the concepts of props and state from React, insofar as props are just destructured parameters which can be reassigned. This means we can do things like compare old and new props like follows:
function *Greeting({name}) {
yield <div>Hello {name}</div>;
for (const {name: newName} of this) {
if (name !== newName) {
yield (
<div>Goodbye {name} and hello {newName}</div>
);
} else {
yield <div>Hello again {newName}</div>;
}
name = newName;
}
}
renderer.render(<Greeting name="Alice" />, document.body);
console.log(document.body.innerHTML); // "<div>Hello Alice</div>"
renderer.render(<Greeting name="Alice" />, document.body);
console.log(document.body.innerHTML); // "<div>Hello again Alice</div>"
renderer.render(<Greeting name="Bob" />, document.body);
console.log(document.body.innerHTML); // "<div>Goodbye Alice and hello Bob</div>"
renderer.render(<Greeting name="Bob" />, document.body);
console.log(document.body.innerHTML); // "<div>Hello again Bob</div>"
How do we compare old and new state with proxies? Not clear.
The above example also shows another advantage of using local variables over state, which is that we sometimes want local state while at the same time letting the parent handle the rerendering. The Greeting
component is stateful, but it only changes when it is rerendered by the renderer. Decoupling local state from the process of rerendering is incredibly powerful and unlocks a whole class of patterns which I think we should explore.
It’s very important that we don’t trigger a refresh while the component is in the process of yielding. This would cause an infinite loop. refresh
should always happen outside a component’s main execution, but using a proxy could obscure the fact that the component is being refreshed.
In short, I think that using proxies would have made Crank executionally opaque, less explicit, less powerful, and paradoxically, more prone to infinite loop bugs. However, I’ll try to keep an open mind, and if there is a way to design a proxy-based API which solves the problems above by refreshing the component asynchronously and coalescing assignments, I’m curious to see what you’d come up with. I’ve been trying to think of a way to create a plugin system so people could dynamically and globally extend the Context
class with their own ideas like this.
However, one thing I would also say is maybe we have different conceptions of what “reactive programming” is? I don’t think reactive = proxies and I think there are a lot of cool reactive programming patterns you can explore, for instance, with async iterators.
For instance, because this
is an async iterable of props, and because async generator components will continuously resume as the component is mounted, you could use something like RxJS’s switchMap
to do something like this:
async function *ChatApp() {
yield *switchMap(this, async function *(props) {
const messages = [];
for await (const message of roomMessages(props.room)) {
messages.push(message);
yield (
<ChatLog messages={messages} />
);
}
});
}
You can think of ways to use the async iterator of props as a source with various combinators to produce elements. That feels closer to reactive programming to me than anything proxies would bring.
I like explicit refresh. In React you do refresh sometimes implicitly by setState , and then you wander how the updates are batched and when they happen. And sometimes you do it explicitly by setting dummy state just to do refresh. This is confusing. And you have all the time to keep in mind these background processes.
state updates often include changes to multiple properties and if we were using a proxy, it’s not clear how to coalesce assignments so that only one update were triggered for a batch of changes. You could batch them by some unit of time but this felt less executionally transparent than letting the user modify the local state of the component how they liked and calling refresh when they were ready.
The pattern is typically called a "trampoline". You push all state updates into an array (well, a deque typically) after a microtick (Promise.resolve().then(process)) you process all of them at once. That has an advantage (batching) but also a disadvantage (sync-ness).
Because Crank uses local variables for state, we can actually blend the concepts of props and state from React, insofar as props are just destructured parameters which can be reassigned. This means we can do things like compare old and new props like follows:
I think that breaks the abstraction, relying on old props is not a great pattern. It's entirely possible with proxies though. With MobX or Vue for example you'd just keep a reference to an old value as a regular JS variable reference.
How do we compare old and new state with proxies? Not clear.
You would just keep a reference to the old value since it's a JS variable?
Decoupling local state from the process of rerendering is incredibly powerful and unlocks a whole class of patterns which I think we should explore.
I agree, though that doesn't necessarily mean no reactivity.
but using a proxy could obscure the fact that the component is being refreshed.
Well, with vue svelte and mobx only the component that is depended on gets refreshed that is:
Also, this made me laugh
I considered using a proxy-based API, but I typically try to avoid proxies because I dislike metaprogramming
Coming from someone working on a framework 😅 🙇♂️
Hm. Am I correct in observing that, because Crank offers some fairly low level primitives, in effect, building a framework on the framework... or really, a common class/function/object to use... that does this kind of trapping and batching would be easily possible, while still leaving explicit refresh the default? It seems to me that Crank offering a useful lower-level abstraction here can and should be exploited. React has its Redux, Crank can have its Clutch.
The conflict I see is with pull/push based semantics on a wide scale. Sure the refresh function could be masked automatically but that does not make something reactive. If React classes state object had been a proxy that would have not made it any more reactive. Batching is still completely possible with a proxy but that's not the issue. The elegance of this solution is the complexity isn't in the data. See reactive libraries push complexity into the data, and VDOM libraries into the View. What is so refreshing here is how transparent the progression of data over time is. I'm not going to say I personally need or want that kind of transparency, but I have to admire it.
Ok let me put it forward this way. The Reactive system that MobX brings to say React is great way to model things especially coming from store propagation but in modern hooks land is basically an analogue. React.memo
(observer
), useState
(observable
), useMemo
(computed
), useEffect
(autorun
). It works completely differently but effectively ends up very similar since we are still working at Component granularity. It lets you prop drill rather than use context to do deeper nested updates but more or less the HOC observer
or memo
decides whether to update the component in conjunction with the primitives. Raw React or VDOM will always be able to expressed in a way that is more performant than the combination of a Reactive system with it, since you are still just using the library to render.
Now put that in scope here. Your components are basically represented in time-based slices, what sort of reactive tracking and updates can it do that wouldn't be better represented using say async generators. I'm trying to picture what the reactive context would be that you would retrigger and what the lifecycle would be. Would you track at each slice and then when one of its values update, clear dependencies and go to the next and track that. Fine Grained Reactive libraries like Vue and Mobx are all about tracking dependencies and re-running something over and over based on those dependencies changing.
Now I imagine not everyone is actually thinking actual Reactive like MobX and Vue and just doesn't want to call an explicit function. To which I'd ask is it really worth it here. Everything the library is doing is pointing to simple data. All this to prevent an explicit function call? There has to be one regardless whether you are hiding it or not. I admit if coming from setState
land an additional function call after I do my stuff seems wrong (although the vast majority of VDOM libraries do this). So I only ask this. Why did react use setState
when they didn't have to? When they could have used forceUpdate
? @brainkim that is what I'd be considering if it were me.
Well, you can use MobX with Crank.
I hacked out a working component
function* Mobx() {
const data = observable({ count: 3 });
const handleClick = ev => {
data.count++;
};
setTimeout(() => {
autorun(() => {
this.refresh();
});
}, 0);
while (true) {
yield (
<div>
The button has been clicked {data.count}{" "}
{data.count === 1 ? "time" : "times"}.
<button onclick={handleClick}>Click me</button>
</div>
);
}
}
Without setTimeout there would be error.
[mobx] Encountered an uncaught exception that was thrown by a reaction or observer component, in: 'Reaction[Autorun@8]' TypeError: Generator is already running
Maybe some kind of wrapper could be created to hook MobX and Crank together, so I think this.refresh
is fine. Leave the reactive library of choose to the user.
What exactly is the motivation of this discussion?
Having to .refresh after making an uncontrolled change rather than reactive programming like MobX, Svelte or Vue is rather frustrating
Finding it "frustrating" that Crank's API is different to other frameworks is entirely subjective (and seems mostly at odds with Crank's goals). Are there any concrete problems with Crank's API that we're trying to solve in this issue?
E.g. someone mentioned that it's possible to forget the this.refresh()
. True. How likely is it that the end user will have this problem? Is it a problem we want to solve? What concrete problems are we talking about here?
What exactly is the motivation of this discussion?
I want to use Crank and I don't want to use an API where writing bugs is easy.
How likely is it that the end user will have this problem?
In my opinion very likely. This has been likely in AngularJS for example (with forgetting $digests), in backbone (forgetting renders) and in other libraries/frameworks (forgetting INotifyPropertyChanged in WPF).
Note that "forgetting" isn't just "I literally forgot", it could be an exception, an unresolved promise with a refresh following etc.
Is it a problem we want to solve?
For me, that's a show stopper and I would not use a tool that's error prone in this particular way. I am just one person and Crank can be wildly successful without my usage or endorsement.
What concrete problems are we talking about here?
It's more about falling into the pit of success. Always having a thing you can forget to do is an API pitfall. That's why I fought so hard for promise APIs where you don't have to check if (err)
every time which developers can forget.
APIs matter, especially at the framework level.
This might be a silly idea, but going with the concept of falling into the pit of success -- what about inverting the logic so that instead of having to call refresh, it refreshes automatically at a certain point unless you call "don't refresh"?
I will just add to this discussion. Some mention that refreshes should be automatic or you might forget to refresh. Also stating that other libraries fix this problem. Actually what problem I have with those other libraries is that they are doing too many refreshes where they shouldn't. Sometimes it's really hard in the complex system to write it in the performant way. So I guess both ways are wrong and have the same problem in nature. So I don't think one is worse or better than another. It's just other way of approaching the same problem: how to do updates in a performant way.
Just a few remarks:
If someone is only interested in simple automatic change detection just for local component state (whether proxy based or using some kind of setState
function or whaever) that should IMHO be possible in user-land (and as soon as Context
will be enhanced with some lifecycle register functions, it will be possible). To a certain extent it is already possible today (see @minipai´s MobX example above or this little demo that was already posted in some other thread: https://codesandbox.io/s/heuristic-golick-i146l ... but of course @minipai´s solution opens way more possiblilties than this little limited change detection in my demo).
As @minipai has already said (quote): "Leave the reactive library of choose to the user."
But @benjamingr's question was not just about automatic change detection only inside of the component, it was about a global change detection mechanism: The question was whether it may be useful to (quote) "make the framework reactive". Of course it's perfectly fine to love this idea and also perfectly fine to hate it, but like I always say, we have to separate personal opinions from facts, and a fact is that Vue is an extremely successful UI library and a very big part of that success is based on the fact that there are a tons of people who consider this type of state management with automatic state detection very, very sexy. And it really makes a big difference whether that reactive stuff is implemented in the core itself or just in user land. If it's in core than all components can be memoized by default and mixing of components from different third-parties will normally not cause problems like you would have with different (maybe even competing) change detection mechanisms or different strategies regarding memoized vs. non-memoized components etc. You import a third-party component and it just works (okay, we all know: At least in theory ;-) ). And by the way: The question "Crank: Reactive or not?" is not just a technical question, eventually it could be decisive for the success of Crank in general.
And I cannot repeat it often enough: All this "Hurray! Vanilla-JS!" and "Hurray! Just JavaScript!" and "Using let
variables make things so easy and transparent" is extremely just a matter of taste. In Svelte a significant subset of those let
state variables will be updated automatically as Svelte is basically a compiler not a library. In Crank (and almost everywhere else) you have to update this let
variables explicitly and this is exact the problem: You really have to make sure that all those let
variables are ALWAYS updated properly, Crank will not help you with that. And if you have a closer look to some non trivial Crank examples out there, in not few of them you'll find that this let
variable updates are not implemeted completely properly or at least it's surprisingly difficult to reason about that.
For example: The other day I've claimed that in the Crank TodoMVC example this kind of faux pas happened even three time in one single component (and these components are quite simple ones).
Maybe I was wrong, maybe I was right ... who knows? ... but try to reason it by yourself and you will know what I mean.
If you compare the example with its React couterpart, you'll see that normally in React you do not have this class of pitfalls that often (of course for example in refs the same is also possible and of course React has its own additional classes of pitfalls):
function Greeting({ name }) {
const prevName = usePrevious(name)
if (!prevName) {
return <div>Hello{name}</div>
} else if (prevName === name) {
return <div>Hello again {name}</div>
}
return <div>Goodbye {prevName}, hello {name}</div>
}
Hey, any reason not to make the framework reactive? Having to
.refresh
after making an uncontrolled change rather than reactive programming like MobX, Svelte or Vue is rather frustrating :]