funkia / turbine

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

Separating models from views #35

Open paldepind opened 7 years ago

paldepind commented 7 years ago

This issue is intended to be a follow-up to the discussion about the model-view separation that @jayrbolton started in #28.

One point I want to make is that I think achieving proper separation between model and view requires that the connection between the model and the view is sufficiently abstract.

Consider the following component below.

function* counterModel({ incrementClick, decrementClick }) {
  const increment = incrementClick.mapTo(1);
  const decrement = decrementClick.mapTo(-1);
  const changes = combine(increment, decrement);
  const count = yield sample(scan((n, m) => n + m, 0, changes));
  return [{ count }, { count }];
}

const counterView = ({ count }: CounterViewInput) => div([
  "Counter ",
  count,
  button({ class: "btn btn-default", output: { incrementClick: "click" } }, " + "),
  button({ class: "btn btn-default", output: { decrementClick: "click" } }, " - ")
]);

const counter = modelView(counterModel, counterView);

This is a simple counter component. The view contains an increment button and decrement button.

Let's say I want to wire up counterModel with a new view that has four buttons. Two buttons for increasing and decreasing the counter by one and two new buttons for changing the counter up and down by 5.

I can't really do that with the above model because the model has direct knowledge about the two buttons in the view. But, I can refactor the code to eliminate this:

function* counterModel({ delta }) {
  const count = yield sample(scan((n, m) => n + m, 0, delta));
  return [{ count }, { count }];
}

const counterView = ({ count }: CounterViewInput) => div([
  "Counter ",
  count,  button({ class: "btn btn-default", output: { incrementClick: "click" } }, " + "),
  button({ class: "btn btn-default", output: { decrementClick: "click" } }, " - ")
]).map(({ decrementClick, incrementClick }) => {
  return { delta: combine(incrementClick.mapTo(1), decrementClick.mapTo(-1)) });
});

const counter = modelView(counterModel, counterView);

What I've done is move the processing of the click events into the view. The model no longer has any knowledge of the detail that the view consists of two buttons. The model can be used with any view that outputs { delta: Stream<number> }.

With this change, I can easily reuse the exact same model and hook it up to the beforementioned view with 4 buttons.

const counterView = ({ count }: CounterViewInput) => div([
  "Counter ",
  count,
  button({ class: "btn btn-default", output: { increment5: "click" } }, "+5"),
  button({ class: "btn btn-default", output: { increment1: "click" } }, "+1"),
  button({ class: "btn btn-default", output: { decrement1: "click" } }, "-1")
  button({ class: "btn btn-default", output: { decrement5: "click" } }, "-5")
]).map(({ decrement1, decrement5, increment1, increment5 }) => {
  return {
    delta: combine(increment1.mapTo(1), increment5.mapTo(5), decrement1.mapTo(-1), decrement5.mapTo(-5))
  };
});

My point is: If a view does a minimal amount of preprocessing to its output then we can achieve a very loose coupling between model and view. The model expects input of a certain type. If that type contains as little knowledge about the view as possible the model will be highly reusable.

jayrbolton commented 7 years ago

Hey @paldepind, I think I should have followed up before to say that I worked through a multi-counter example with Turbine last week and I'm now convinced on the reflex-style architecture combined with modelView.

Some separate but related considerations include...

About packaging components: In flimflam, one of our internally most-used component was a Validated Form. We initially packaged up the view functions together with the UI logic. But later we decided to abstract out the UI logic into just a plain function that validates objects, and we were finding use cases everywhere, like the backend. Of course, this kind of approach is fully compatible with Turbine. I would propose trying to abstract the UI logic as much as possible and then have the "model" be a client to all your abstracted logic.

I think there are also cases where models should be packaged alone. For example, an ajax-focused "model" that creates/fetches/updates/deletes some resource might be better to be domain-logic-only. That resource might then be presented in a myriad of ways on different sections of a page. Likely you would initialize that model in the top-level component of the page and pass it down into other models, rather than re-initializing it in multiple child models.

Another different but related issue is how to style markup that's been provided in a component . This probably belongs in another thread, but I'll touch on it briefly here. In flimflam, we initially provided default classnames with the ff-* prefix and some basic, functional styles, and we'd show the classnames in the docs. The user was then free to override those styles using the given classnames. However, we also had a ton of success adopting BassCSS, which has you chain lots of little existing classes rather than creating new CSS classes. So that was another reason that we stopped providing markup in the components: to allow people to insert their own BassCSS classnames in all the elements. In Turbine, since the view functions are not re-evaluated on every state change, there may be an opportunity to think about better ways to style components programatically.