funkia / turbine

Purely functional frontend framework for building web applications
MIT License
685 stars 27 forks source link

Is combining model and view possible? #81

Closed stevekrouse closed 5 years ago

stevekrouse commented 6 years ago

I know there are a lot of other issues discussing the pros and cons of separating the model and view. I'm simply wondering if it's currently possible to do given the current architecture, and if so, if someone could give me an example of it.

My motivation is that I like how in Reflex you can combine the model and the view seamlessly, but also separate them if you want. I like the extra freedom. The modelView function and the architecture it imposes feels a bit restrictive to me.

paldepind commented 6 years ago

As of right now, the API in Turbine is biased towards separation of model and view. Personally, I feel that the mixing of logic and view in Reflex is messy. But, on a fundamental level, there is nothing in Turbine that prevents one from doing something similar to what is possible in Reflex. The Component monad has a Now inside it. So everything that can be done in Now can be done in Component. Furthermore, the API could easily make this more comfortable for this.

The first step would be to create a function that makes it easy to run Now computations inside a Component. Such a function could be created based on modelView as below.

function liftNow<A>(now: Now<A>): Component<A> {
  return modelView(() => now, () => emptyComponent)();
}

The above function creates a component that runs the given now computation, outputs its result, and then in the view does nothing (emptyComponent corresponds to no view). This function could also be added directly to Turbine as a primitive.

With this function, we could do something like this

const counter = go(function* () {
  const { click } = yield e.button("Click me");
  const count = yield liftNow(H.sample(H.scan((_, m) => m + 1, 0, click)));
  yield e.span(H.format`Button pressed ${count} times`);
});

Here is a CodeSandbox for the code above.

This example combines both the model and the view. The only small difference compared to Reflex is that we have to use the liftNow function whenever we want to do stateful FRP computations. If I recall correctly, in Reflex their thing corresponding to Now is not a type but a type class and this type class is implemented by their Widget type (which corresponds to our Component). I don't think having to use liftNow is a problem. Lifting in monads is very common. But, we could do the same thing as Reflex and create some type class (or the equivalent thing in TypeScript). The downside to this is that it increases the complexity of the types which makes things slightly more tricky and less transparent.

Does that help you? What do you think?

stevekrouse commented 5 years ago

Wonderful! I'm trying to apply this to my standard "buttons that make buttons" problem, but am having trouble using it in conjunction with loop.

const buttonsThatMakeButtons = loop(({ output, count }) => function*() {
  const incrementClick = switchStream(
    output.map(l => combine(...l.map((o, i) => o.click.mapTo(i + 1))))
  );
  const count_ = yield liftNow(sample(
    scan((n, m) => n + m, 1, incrementClick.map(x => (x % 2 === 0 ? 0 : 1)))
  ));
  const output_ = yield list(
    b,
    count.map(c => Array.from(Array(c).keys()).map(x => x + 1))
  );
  return { count: count_, output: output_ };
});

Creates the error Attempt to sample non-replaced placeholder

Can you help?

https://codesandbox.io/s/w2kvqr5nw8

paldepind commented 5 years ago

This appears to be a bug that was fixed in the latest version of Turbine.

Here is a Codesandbox that, as far as I can tell, works as expected: https://codesandbox.io/s/m9o1ok1vyy

The only things I have done is

- const b = i => button({ output: { click: "click" } }, i);
+ const b = i => button(i).output({click: "click"});