act-framework / act

⚡️ A simple reactive front-end framework
http://act-framework.github.io/act/
84 stars 6 forks source link

Change proposal #36

Open joaomilho opened 8 years ago

joaomilho commented 8 years ago

So after a couple of weeks reflecting about it I'm about to make a major change to the way act works. Most of the ideas behind it come from discussions I had with @xaviervia and some other people doing "heavy" UI work here in Israel. So here are the principles:

Example:

const outputs = [someFunction]
const inputs = {position}
const reducer = ...
main(outputs, {inputs, reducer})

What this does is the following: position is a subscription (that already exists) that emits an action w/ the current mouse coordinates when the user moves it. This part works the same as it works today. Now, someFunction, being an output will receive, by default, the history on every change, so it can do anything with it, like simply log the state:

const someFunction = (history) => console.log(history.state)

This app, as you can see is not about HTML/DOM, and if we changed the sort of input to something that doesn't deal w/ a browser it could be a node app.

Now, since these input functions receive the history on every change, they could be extended to be more handy, e.g. to deal with HTML/DOM:

const outputs = [dom(view)]

So dom receives a function and returns a function (dom :: (a->b) -> (a->b)). Instead of passing the history it passes only the history.state to the view function, and it could even check if it changed (to make it easier the default History should always have the current and the previous state). It also gets the result of calling view and renders the nodes. This way we could have these sort of functions for different render engines.

So far so good. Now let's say we need to change the url when a user clicks a button. Before we would attach an action to a button and perform the side effect right into the action. This is the way most people do it, but most of the time (if not all of the time), they also have to change the state to keep this info somehow (think about the classic loading flag). With this proposal it will work the other way around: all user interaction change some state, and state changes trigger side effects (like DOM always worked!). So it would look like this:

const model = {..., url: '/', ...}
const view = [... ['button', {click: ['nav', 'foo']}, 'Nav to foo'] ...]
const reducer = ... case 'nav': return {...state, url: payload} ...

const navigate = (url) => location.href = '#' + url
const outputs = [lens('url', navigate)]

So lens (or some better name) gets a path (as in Ramda's lens paths) and a fn, and it "watches" the history for changes on this path, that is, if url changes. When it changes it will call navigate and perform the side effect.

Of course, many side effects could be even more specific, like subscriptions are nowadays, so we could have things like route, fetch, etc...

On KIS

At last but not the list, to keep the feeling of simplicity, outputs could be a list or an item. If it's an item act would suppose it's about HTML. So if one does main(dom(view)), or even main(view) we could automatically wrap it into a dom handler. Therefore, main("Hello world") would still work.

xaviervia commented 8 years ago

Awesome. I totally agree with every single decision. I especially like the name changes, because it makes it very clear the direction of the data flow and where the history is located in it.

About that I've been noticing that right now there is in the JS community as a whole a debate that hasn't been explicitly formulated yet between whether side effects should be done in a composition with "monads" (with Promise for example) or as a subscription to a global state update. The main difference (and a big one in my opinion) is that with monads you lose the traceability of the side effect in the global state, so both for the purposes of debugging and analytics and immutability, chaining side effects in Promises is weaker than putting everything on the state. But at the same time is considerably less verbose (otherwise you need to model the part of the state that reflects the side effect and add a reducer for it, and then a subscriber).

To recap the two possible ways, in the "Monad/Promise" approach:

const getTranslationAsImage = (word) => new Promise((res) => {
  someTranslationService(word, (translation) => res(translation)
}).then((translation) => new Promise((res) => { 
  getGIFForWord(translation, (gifURL) => res(gifURL)
})

const getTranslationAsImage('vaca').then( ... )

And the state approach:

const reducer = (state, action) => {
  switch (action.type) {
    case 'translate':
      return { ...state, pendingTranslation: action.payload }
    case 'translated':
      return { ...state, translated: action.payload, pendingTranslation: undefined }
    case 'imagefied':
      return { ...state, gifURL: action.payload, translated: undefined }
  }
}

const outputs = [
  lens('pendingTranslation', (pendingTranslation) => someTranslationService(pendingTranslation, (translation) => 
    history.push({ type: 'translated', payload: translation })
  )),
  lens('translated', getGIFForWord(translated, (gifURL) => 
    history.push({ type: 'imagefied', payload: gitURL })
  ))
}

history.push({ type: 'translate', payload: 'vaca' })

Now my point here is that, as I see it, Act supports both, and in a way encourages to hybridize between them, and that's a good thing. I believe there are examples such as the one with the word in which reflecting all the side effect process in the state is just cumbersome and doesn't give much benefit, since the value of restarting the process from the moment the translated word was retrieved is questionable. There are plenty other side effects however that are a semantically relevant application state (the most important that comes to mind is "saving", in which the "in flight state" of attempting to save is useful for the UI to be able to show a spinner), and for those it would be problematic not to have the history option.

I think there is probably room to explain how to handle this more in detail, since for example Monad/Promise like chaining in a history driven architecture can be done in two parts: upstream (before the action is created and pushed to the history) and downstream (as part of refining the side effect). A clear example of upstream is filtering out click inputs so that they only reach the history if the user was also pressing the key "Ctrl". A clear example of downstream is retrieving the translated gif image like in the example above.

This is a little bit of a side track of the change proposal, but I have the impression that with the update that you are proposing Act will approach even more the architecture that I'm describing above, so I wanted to put down some thought about that. Now it's pretty easy to see that side effects done as part of an input are chained upstream and side effects done in an output are chained downstream: maybe those concepts might help with understanding why there are two ways of doing side effects, and when to use each. It also makes it pretty clear that history side effects don't have to be at all related to the DOM.

joaomilho commented 8 years ago

Cool.

  1. What you mean by "upstream" is already dealt in act with the signal system. It's absolutely simple to do this sort of thing (get an event, extract a value, filter click inputs / when Ctrl is up, etc etc), and this part is not about to change.
  2. About the boilerplate: The reducer part can be reduced by using Ramda properly, and my idea is to focus ion on solving the boring bits.
  3. The unknown is how this helps composition of components (by component a mean a group of data+inputs+outputs). But I'm sure it will.

In fact I already have an implementation almost there, and it is beautiful how it simplifies the code, specially in terms of readability.