funkia / turbine

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

User friendliness and other considerations #28

Open jayrbolton opened 7 years ago

jayrbolton commented 7 years ago

As I mentioned in this thread, I have a lot of time right now to dedicate to building out components for a frontend library/framework. I was going to change the name of [flimflam]() to [Uzu](), add some improvements, and reorganize the way some of the modules are set up. However, I'd much rather join forces with another project.

I have some major hangups with Turbine, not with the concepts, but more around accessibility. For example, I would never feel comfortable introducing Turbine to the Oakland.js meetup group, because its heavy dependency on Typescript for examples, typescript signatures, and haskell-style functional abstractions would alienate the Node.js community out here.

However, I think it would be possible to basically "lift" Turbine above these more esoteric aspects. That is, what if there was a "layer" for this library's documentation that used only plain JS, no typescript, no typescript signatures, and common JS?

So this would be the "public layer" --- the topmost, immediate documentation that allows any common javascript developer to make an application.

Then, when the developer decides that they want to dig into the functional abstractions more, such as Jabz and the semantics of Hareactive, then they could view "deeper" documentation, perhaps in a wiki, that might show Typescript signatures and Haskell type classes, and so on. But these things would be under an optional "advanced" layer of documentation.

Strongly separating models from views

(This is more of an architectural consideration rather than accessibility)

In my recent experience building large-scale apps using flyd and snabbdom, we have come across one key principle that always helps in the maintainability of the app: strongly decouple Models with Views. The data tree of your markup should not try to match the data tree of your state and ui logic.

Models should even live in their own files and have no awareness of the views. Views should read from models and generate actions for them, but not much else. On examination, I think this is all very possible with Turbine. It would be nice if the documentation encouraged this style.

I actually have a real-world example of where this is important. On a single-page app, we have two sections: one section (section A) is for creating, updating, listing, and removing users, while the other section (section B) is for listing users, and for creating/updating/listing/removing nested user_roles. Both sections use different nested forms for all updates. Creating a user from section A should cause the user listing on section B to update. Likewise, creating a user_role in section B should cause the user listing in section A to update.

Most developers, using a "component" style, will have an instinct to create two separate components for section A and section B, each with their own separate models, stream logic, etc. However, they will have a terrible time trying to communicate the users back and forth between the two sections, and will find themselves with numerous cyclic dependencies, or resorting to mutating globals.

However, if that developer instead created a simple User model, and a UserRole model, which both used FRP to create/update/delete/list the users and user roles, without any markup, then they could simply pass instances of those models down through the views. The developer would never have to worry about redundant data between two siblings, cyclic data, redundant ajax logic, etc. The UI logic then becomes very easy.

The key here is that developers often have the instinct to map their data tree to their markup tree,. Say they had an html tree that generally looked like this:

markup:
  sectionA div
    table for users
    button for adding a user
    form for creating a user
  sectionB div
    table for users
    nested tables for user roles
    button for adding a role
    form for the role

Most developers would then have an instinct to create UI data and models with this structure:

data:
  sectionA
    isOpen: true/false
    users: array of user objects
    loading: true/false
    etc
  sectionB
    isOpen: true/false
    users: array of user objects
    user_roles: array of role objects
    loading: true/false
    etc

You can see how we are going to get cyclic dependencies between the sectionA model and sectionB model, because we want both sections to both read and update users

If the developer instead made their data model without thinking of the views, they would probably come up with:

data: 
  users:
    loading: true/false
    isEditing: true/false
    data: array of user objects
    user_roles:
      loading: true/false
      data: array of role objects
      etc

Here you can see that we no longer try to match the data tree to the markup tree, and we will eliminate cyclic dependencies.

Sorry if this is all too sloppily abstracted. Perhaps this real-world example would be a perfect practice test example for me to try with Turbine, so I can illustrate what I'm talking about with real code.

dmitriz commented 7 years ago

@jayrbolton

If I may try to respond to some objections...

I have some major hangups with Turbine, not with the concepts, but more around accessibility. For example, I would never feel comfortable introducing Turbine to the Oakland.js meetup group, because its heavy dependency on Typescript for examples, typescript signatures ...

While I have also initially felt repelled by the TS (or the FB Flow) and still don't actively use it myself, I don't anymore feel it as obstacle to read other people's code. Often the TS annotation helps me a lot to understand the types and interfaces and how the functions are used. It works beautifully inside Sublime with popups showing the function definitions and links I can click, leading me instantly to other files where those functions were defined. I find it powerful. Compare it with manually doing multiple searches over nested code directories when searching for function definitions, and worse, having to go through the code to understand which types are where.

Further, any TS code is transpiled into plain JS that you can read instead. And as far as I know, the TS transpiled code is supposed to be more readable than Babel's. That means, you don't even need to read the TS files and you can import and use just the plain JS versions.

... and haskell-style functional abstractions would alienate the Node.js community out here.

That is a different issue, and a long one :) But before going there, I would strongly encourage to look at the very practical examples in https://github.com/dmitriz/functional-examples and the links to courses and videos there. Especially the written course by Dr. Frisby is amasing particularly on the practical side. Having read it, most of the currently produced software feels like 90% nonsensual fluff :)

When I posted that link on reddit, it generated tremendous number of upvotes: https://www.reddit.com/r/javascript/comments/5e3lcc/professor_frisbys_mostly_adequate_guide_to/

This shows that the abstract Haskell-inspired FP concepts are getting well accepted and embraced by the broad community.

Here is a nice example: https://github.com/dmitriz/functional-examples/blob/master/examples/13-task-async.js#L80

paldepind commented 7 years ago

As I mentioned in this thread, I have a lot of time right now to dedicate to building out components for a frontend library/framework. I was going to change the name of flimflam to Uzu, add some improvements, and reorganize the way some of the modules are set up. However, I'd much rather join forces with another project.

I would love to have you onboard. I've thought about reaching out to you for a long time. But initially Turbine wasn't quite ready and then I never got around to do it.

I think the idea of having approachable documentation is a really good idea. I definitely think that it is doable. It is possible to explain Turbine in a down to earth fashion that makes it seem reasonable and approachable and not like some theoretical exercise.

You are right that the documentation shouldn't be tied to TypeScript. But, @dmitriz has a good point that even people that don't use TypeScript may enjoy having types. For now, I think we should avoid types in tutorials. Long-term it would be nice to have a website where readers of the documentation can toggle types on and off. I.e. toggle between TypeScript and JavaScript.

The only thing you mention that we can't do right now is completely hiding generators. We can hide them in the view. But we need them in the model since the model uses Now. Now is a bit tricky to understand. And your concerns about being approachable has left me wondering if we should make Now optional for users.

Now solves FRPs notorious problem regarding implementing stateful streams in a purely functional way. I define a stateful stream as a stream whose current value depends on the past. An obvious example of that is scan. It is actually really tricky to implement scan in a way that is both intuitive, pure and memory safe.

In my FRP blog post I implement scan in a way that is pure. But the implementation also requires streams to remember their entire history. And that is a huge memory leak.

In libraries like RxJS scan is pure but it is also very unintuitive to use. Basically scan only starts to accumulate state when someone starts observing its result.

In Flyd scan is implemented in a way that is simple to use but also impure.

var numbers = flyd.stream();
var sum1 = flyd.scan((sum, n) => sum+n, 0, numbers);
// time passes events occur
numbers(2)(3);
var sum2 = flyd.scan((sum, n) => sum+n, 0, numbers);
// time passes more events occur
number(4)(1);
sum1(); // 10
sum2(); // 5

Here sum1 and sum2 has exactly the same definition and yet their value is different. So two equivalent calls to scan result in two different streams. That makes scan impure.

The result of scan depends on the time at which scan is called. Because a call to scan will create a stream that only accumulates from the current time and forward. But, saying that the result from scan depends on time is the same is saying that it is a function of time. And a function from time is a behaviour. So in Hareactive scan will actually return a behavior of a behavior.

Now represents a computation (or a function) that will always be executed at the current time. Inside a Now we can do things that depend on the current time in a pure way. For example, we can get the current value of a behavior. That is used for instance in right here in this example. scan is called and returns a behavior of a behavior. Then the outer behavior is sampled which gives the inner accumulating behavior.

To sum it up:

My explanation isn't the best and it is a bit tricky to understand. Especially if one is not familiar with monads and how monads can be used to built computations. Also, the benefits of doing that may not be obvious to many people. So, maybe we should offer an impure alternative to using Now? In this way, people can avoid generators entirely at the cost of having impure models.

About strongly separating view from models. I agree with you and modelView should allow for that. It is something that @limemloh and I have talked about. That a model can be reused with different views or included in another model.

What do you think about having tightly coupled models and views in small self-contained components? Like a slider implementation?

jayrbolton commented 7 years ago

Thanks to both of you for the comments. The description of the impure scan is enlightening; i have definitely hit up against this kind of thing before in flyd and usually find myself reordering my stream definitions, thinking about the event flow imperatively, in order to get the correct accumulation of values. I am going to work through some Turbine examples to try to better understand Now and also the architecture in general, and then I'll get back to you.

What do you think about having tightly coupled models and views in small self-contained components? Like a slider implementation?

I would strongly argue against this, actually. Say your slider is nested 20 levels deep at the very bottom of your markup tree on your page. What if you wanted to also read and write the exact same value at the very top of your page in totally different markup? To solve this, you could initialize the data and logic for the slider at the topmost level of your app. Then you can pass the data down into multiple views. You can then initialize the markup for the slider totally separately from initializing its data and UI logic. This is actually a core principle i wanted to move towards in flimflam/uzu. I'm not sure how exactly it can relate to Turbine. I think I need more coding experience in Turbine first to be able to think about it.

I suspect this is the exact same reasoning they had in creating the original MVC pattern in Smalltalk way back in the 70s for GUI programming. However, I'd rather do it in a purely functional way, and not in the OO style they had.

I give more examples about what I'm talking about in this thread as well: https://github.com/uzujs/uzu/issues/2

jayrbolton commented 7 years ago

Related to my mention of 'classic' MVC and Smalltalk above, I also wanted to link this really good Martin Folwer article: https://www.martinfowler.com/eaaDev/uiArchs.html. Figuring out how to reconcile the ideas from that article with FRP should be interesting.

paldepind commented 7 years ago

Thank you for sharing your experience and insights @jayrbolton. This is not something I have been thinking a lot about. But your explanations about how coupling model and view can be harmful definitely resonates with me.

I think that what I meant when I asked about the slider above is, what do you think about transient state, view specific logic, and stuff like that? I.e. state that is only relevant to the view? Something like headerbarColor = model.isAdmin(b => b ? "black" : "white"). That is relevant to the view but not really part of my model. Do you allow for keeping such state tightly coupled to the view?

jayrbolton commented 7 years ago

@paldepind I'd still argue that it should be decoupled. There are definitely cases where some state in the view seems like you would never ever use it outside the view. For example, you might have a dropdown with a boolean of open/closed. Surely, that is is only relevant inside this view. However, you will suprprise yourself! Some developer will make a UI where opening one dropdown in the bottom right corner of the page will cause another dropdown in the top left of the page to close.

I definitely advocate for different types of models: having domain-logic-only models (eg. an Admin model that only does ajax and validation and has no formatting or anything) and then a separate model/function for formatting the admin's name, or creating a status color, etc. But I think you will inevitably find that you will want to read and modify the admin's status color in many different places in the page, in multiple views.

So I advocate that you should initialize all your data at the very root level (even the dropdown boolean!), and pass your entire data structure into your root view function. Then your root view function can handle passing the different data to the different parts of your views. You data structure can be highly nested. However, the key here is that the nesting of your data structure has no correspondence to the nesting of your views. Also, the data structure is initialized independently of the views.

I am not sure how you reconcile that with the Turbine or reflex style pure functional architecture. If it can be reconciled, then I think we will be golden!

Aside: On Sunday, I worked through all the "professor frisby" videos that @dmitriz linked. I actually did an undergraduate program in computer science years ago that was almost 100% focused on Haskell, and we had a course that worked through the algebraic data types. But I have to admit I have not used those concepts in javascript in any real work, and I was definitely rusty on them. After that video course, however, I am convinced that we should use them in our libraries now. I also think that they can provide method-based APIs for data types that developers can use with zero awareness of the underlying algebraic structures or terminology, so would be infinitely more accessible. I am going to play with all of this over the coming week.

dmitriz commented 7 years ago

UPDATE. The RFP blog post above seems to have broken link, so I am posting it here: http://vindum.io/blog/lets-reinvent-frp/

@paldepind

I think that what I meant when I asked about the slider above is, what do you think about transient state, view specific logic, and stuff like that? I.e. state that is only relevant to the view? Something like

headerbarColor = model.isAdmin(b => b ? "black" : "white")

That is relevant to the view but not really part of my model. Do you allow for keeping such state tightly coupled to the view?

I would first abstract away it into a general purpose function that takes a predicate and pair of styles and applies one conditionally. Then you can reuse it with two separate data -- predicate logic and the style collections. For what it is worth.

dmitriz commented 7 years ago

Aside: On Sunday, I worked through all the "professor frisby" videos that @dmitriz linked. I actually did an undergraduate program in computer science years ago that was almost 100% focused on Haskell, and we had a course that worked through the algebraic data types. But I have to admit I have not used those concepts in javascript in any real work, and I was definitely rusty on them. After that video course, however, I am convinced that we should use them in our libraries now. I also think that they can provide method-based APIs for data types that developers can use with zero awareness of the underlying algebraic structures or terminology, so would be infinitely more accessible. I am going to play with all of this over the coming week.

Brian does really good job by showing real usefulness for the abstract concepts. You can really cut down the nonsense to very few abstract polymorphic functions.

Sadly he does not post a lot, and similar quality explanations are rare. I recently came across the http://www.tomharding.me/ blog that Brian personally recommended on twitter, which seems to be both good and active.

paldepind commented 7 years ago

@jayrbolton

There are definitely cases where some state in the view seems like you would never ever use it outside the view. For example, you might have a dropdown with a boolean of open/closed. Surely, that is is only relevant inside this view. However, you will suprprise yourself! Some developer will make a UI where opening one dropdown in the bottom right corner of the page will cause another dropdown in the top left of the page to close.

But, what if the dropdown with a boolean actually is only ever used inside that view? Wouldn't it then be beneficial to actually encapsulate it inside the only component where it's ever used? Then, of course, later you may find out that the boolean should be used in another place. But in that case, due to the way Turbine works, it will be easy to let the view output the needed value and use it in that other place.

For instance, let's say you've created a component A far down that has some private state. You then figure out that the state needs to be shown in another component B that is in a different part of the application. Then you can simply let A output the previously private state and pipe it up the tree and down into B?

So I advocate that you should initialize all your data at the very root level (even the dropdown boolean!), and pass your entire data structure into your root view function.

I agree that you gain something by doing that. But I also think you may lose something. Keeping all data at the very root level will obfuscate that some state actually is only used in a single view?

Wouldn't it make more sense to keep state as "low" as possible? And then simply move it up when needed?

I think, but I'm not sure, that maybe some of the problems you've experienced are not as problematic when using Turbine.

jayrbolton commented 7 years ago

I think, but I'm not sure, that maybe some of the problems you've experienced are not as problematic when using Turbine.

I don't doubt this could be true, and before we discuss it further I think it will be worth me coding up some examples. I have some time today for it, in fact. I think in particular I need better experience with the idea of piping data up the view tree and not just down