bikeshaving / crank

The Just JavaScript Framework
https://crank.js.org
MIT License
2.7k stars 75 forks source link

Reactivity #37

Open benjamingr opened 4 years ago

benjamingr commented 4 years ago

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

JasonMatthewsDev commented 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.

benjamingr commented 4 years ago

@JasonMatthewsDev have you seen how reactivity systems like Vue and MobX works?

Basically:

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.

JasonMatthewsDev commented 4 years ago

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

benjamingr commented 4 years ago

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

JasonMatthewsDev commented 4 years ago

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.

brainkim commented 4 years ago

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:

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

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

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

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

lishine commented 4 years ago

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.

benjamingr commented 4 years ago

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:

benjamingr commented 4 years ago

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 😅 🙇‍♂️

workingjubilee commented 4 years ago

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.

ryansolid commented 4 years ago

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.

minipai commented 4 years ago

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.

wmadden commented 4 years ago

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?

benjamingr commented 4 years ago

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.

ganorberg commented 4 years ago

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"?

lukejagodzinski commented 4 years ago

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.

mcjazzyfunky commented 4 years ago

Just a few remarks:

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>
}