jorgebucaran / hyperapp

1kB-ish JavaScript framework for building hypertext applications
MIT License
19.08k stars 780 forks source link

Components #238

Closed jorgebucaran closed 7 years ago

jorgebucaran commented 7 years ago

Let's discuss how components could look like in a future version of HyperApp.

Submitted Proposals


Example

counter.js

const Counter = (props, children) => ({
  state: {
    value: props.value
  },
  view: ({ value }, { inc, dec }) =>
    <div>
      <h1>{state.value}</h1>
      <button onclick={inc}>+</button>
      <button onclick={dec}>-</button>
    </div>,
  actions: {
    inc: state => state + 1,
    dec: state => state - 1
  }
})

index.js

app({
  state: {
    title: "Hello."
  },
  view: (state, actions) =>
    <main>
      <h1>{state.title}</h1>
      <Counter value={1} />
    </main>
})

Credits

All credit goes to @MatejMazur:

Related

MatejBransky commented 7 years ago

@jbucaran Yeah, I know that but this is not problem. 😄 Problem was with components everywhere (as prop in app, as argument in view, without destructuring you need write longer version of arrow function with return statement (assigning components to variables) or you don't use JSX for components, and it's not usable for more than 5 or 10 components). And even if you nest components it still doesn't look cleaner. (I tried that with my first example in Slack and you were right that it's full of new things) I hoped that someone knows how to achieve that with HyperApp because I didn't know how but now I know that it's not possible with current API.. 😝 so you can move on how you want! 👍 I don't have any other ideas.

zaceno commented 7 years ago

@jbucaran Sure I'll put a PR together. And perahaps a demo of components using other components. And mixins with components (a k a Widgets :) ) It's good to have concrete examples to discuss :)

Swizz commented 7 years ago

Single state tree, single actions tree and single component tree. With a system of namespaces, namespaces for component name and namespaces for isolation ID like it does in Cyclejs.

Everything live into the same objects at start, but is pretty isolated. When the view function of component is called, we give to it a substet of the tree according to its isolation.

Like a Fractal. Because my english is poor : Fractal state/actions explanation

So, we can create component into seperate file, but when a component as added to an app, it will be merged in isolation.

This will allow to use componenet as app too :

app(Counter(props));

And according to use case, the actions dec: (state, actions) => state will also receive as input a subset of the state and action according to the Component which is the caller.

MatejBransky commented 7 years ago

@Swizz Yes! That's exactly what I want to achieve (your graph)! That's how I did it but I don't know how to store and calls instances by id if it's allowed to have multiple apps because I don't know how to say to component which app called it..

Swizz commented 7 years ago

I think this is not too difficult. When a component is registered/instantiated, we can give it a random ID, if one is not given (like key for vnode).

I did something similar with mixins and ES6 features https://github.com/hyperapp/hyperapp/issues/238#issuecomment-310999839

MatejBransky commented 7 years ago

@Swizz Yep, but again, you just need another prop in app. So we're back at the beginning. I achieved this without another prop and arg but I don't know how to keep communication between the right app and component - so you need id for app but then you need to tell to component which app calls it.

zaceno commented 7 years ago

@Swizz @MatejMazur just don't forget about the need to access the rest of the state and actions. A counter is a very ~unusual~ untypical example, in that it's own state/actions are enough for it.

I'd be (honestly!) interested in seeing an implementation of an example where the components as well as the main app need to access eachother's state and actions.

Swizz commented 7 years ago

Component are stateless, so, we do not have component which are part of two apps. Here is the same "Model" but here is two different component.

As the Component and the App live with the same State and Actions tree, this is easy to handle parent->child comunication, the Parent could call every action of its childs. And for child->parent, this is as easy as adding a .parent into the partial component State and Actions tree.

MatejBransky commented 7 years ago

@zaceno This isn't difficult because you can set which part of state would be accesible. So we can create for example something like:

component(state, actions, props, children) => {... 
// call app state
state.global.something
// call component state
state.something
}

You can just set state before calling component. (this is easy doable)

MatejBransky commented 7 years ago

@Swizz But do you think that it's not possible without some dependency prop (depends: ...) like you have in comment, don't you? Because it increases boilerplate..

zaceno commented 7 years ago

@Swizz & @MatejMazur Yeh, it's possible sure. Still I feel like in a more typical example with lots of cross-component-state-sharing, it wont look as clean and nice as the counter examples. In fact I suspect that not using fragmented state will look cleaner in those more typical cases. But I'd love to be proven wrong!

We need a more realistic app-example to demonstrate our proposals on.

MatejBransky commented 7 years ago

@zaceno It it's possible than it will be much more cleaner because you don't need to know full path to instance's state, actions etc. You just simply build component like this.

Swizz commented 7 years ago

We can rearrange the API to be more user friendly. And in fact, accessing parent state/actions is a terrible pattern.

In most case, this is more comfortable to use properties for parent->child communication and events for child->parent comunication

import { h, app } from "hyperapp"
import Counter from "./counter"

app({
  state: {
    title: "Distance",
    unit: "km",
  },
  view: (state, actions, components, emit) =>
    <main>
      <h1>{state.title}</h1>
      {components.Counters.map((Component) =>
        <Component/>
      )}
    </main>,
  components: state => ({
    Counters: [
     <Counter initialValue={5}, unit={state.unit}, onchange={value => emit('changed', value)}/>
    ]
  }),
  events: {
    changed: (state, actions, value) => { alert(value) }
  }
})
zaceno commented 7 years ago

@MatejMazur Yeah I know but: a) Your example is still using counters, which do not demonstrate typical complexity. b) You can use assocPath to bind/scope actions & state to specific state-branches. Why bake it into a component solution and make it non-optional?

MatejBransky commented 7 years ago

@Swizz Agree with parent state/actions. I just want to argue that it's possible. 😉

Swizz commented 7 years ago

@MatejMazur We can add a parent propertie to each substract of state and actions tree for those who like to deal with terrible patterns.

@zaceno Do you have a more complex example in mind ?

MatejBransky commented 7 years ago

@zaceno You can use assocPath and that's how I do but it can be done automatically. If you build app you don't want work with something what is not related to business logic. And there is still possibility to use assocPath but you still need to know so many things about parent and that's antipattern. Component based approach is about ignorance of parents. 😄 For example counter doesn't need to know how many instances has or where is used it wants to know only own state, actions and inserted props and children. That's all.

MatejBransky commented 7 years ago

@Swizz My solution was based on outer event mediator which sends instance's state, actions from app to component there it connects these things together then it returns new instance's state to app for storing. But this mediator didn't know id of app so if you want multiple apps then I don't know how to achieve that.

MatejBransky commented 7 years ago

@Swizz We can easily create example by using any existing project and our goal would be just insert this project as component to another project (e.g. insert tic tac toe to website built with HyperApp). The purpose of component is simple. You just change/add a few things and it can work like component in another app. In my example you just add name prop and change app() to component() (changes in tic tac toe) and that's it.

zaceno commented 7 years ago

@MatejMazur It's no secret that I like the component based approach. I like not having to know about stuff 😜 ,

Still don't think hyperapp should to do anything special with states/actions. In my world-view, the counter-examples could be their own apps.

I like @Swizz idea of passing the emit function to views. @jbucaran suggested that too at some point, but can't remember the context.

zaceno commented 7 years ago

@Swizz More typical example imo: Anything with forms and validation. There could be multiple types of inputs (sliders, calendar widgets, autocompleting text inputs) and they would each have validation rules based on what was in the other fields. (Like if you selected a weekend in the calendar app, you could only select a certain range on a time slider, stuff like that)

And they would all have to disable while the form was being submitted. Some fields would require a backend server call for their validation.

MatejBransky commented 7 years ago

@zaceno everything related to communication between components is based on storing props in their parent so this is not purpose of component but rather philosophy behind one way data binding.

zaceno commented 7 years ago

:) I think I should just shut up and let @MatejMazur @Swizz @naugtur and @andyrj settle this...

(Because you all seem to be somewhat on the same page, and I don't want to come in and mess that up)

MatejBransky commented 7 years ago

Imagine reverse React+Redux approach. Stateful components in React are large apps in HyperApp (multiple apps) and Redux/Elm approach is similar to behavior in these apps. App knows everything inside even local states of components (keep in mind that these states are stored in app.state). So you use mutiple apps only for really large and totally independent parts and components will be used for building these apps.

Swizz commented 7 years ago

@zaceno In your example, in my mind, Disabling an input is the job of the parent not the input itself.

I am comming from Vue an Cyclejs, and from both, Component isolation is a panacea. Component is not allowed to think outer their own business.

You are attempting to give to more power to component. That why we are alot against Hyperapp capabilities. Because we are not aligned with Elm principle.

A component is a function. And an app is a component which is the root of the component tree.

Swizz commented 7 years ago

Stateful components in React are large apps in HyperApp (multiple apps)

That why, I am moving from the Hyperapp router to a global one to settle an Hyper'app per route.

zaceno commented 7 years ago

@Swizz

And an app is a component which is the root of the component tree.

All I'm saying is: if you want to structure apps that way with hyperapp, then an app is a tree of apps. If you also want a common state-store for all your components/apps, like Vuex, then that's another app (your store app). That's fine with me.

Doesn't mean we need to change anything about how app() works (maybe there are some minor things, but not sure what yet)

MatejBransky commented 7 years ago

@Swizz could you join to Slack channel? We can try to build something. 😄

Swizz commented 7 years ago

@zaceno

Here, we took the decision to maintain only one store (Vuex in your example) and give to each component a subset of this store according to its isolation. We have only one State and Mutators tree by app.

A component is just a way to add features to your app. But it is not either a completely independent feature. When we are talking about component, we are just talking about a Blueprint to add to the app, to give it more functionnalities, like a mixins but that could be composable and mutlipliable.

Thats why, I immediately jumped into the mixins logic to present my solution. Because components are super-powered isolated replicable mixins.


@MatejMazur I cant use Slack at work, yeah Slack is for work team, but my IT department consider it as "distraction". But gitter is fine :laughing:

jorgebucaran commented 7 years ago

@Swizz Let's go back to this comment for a second.

The problem I have with that is the Counter's actions:

  actions: {
    Counter: {
      up: ({Counter: state}) => ({ Counter: merge(state, { [props.key] : merge(state[props.key], { count: state[props.key].count + 1 }) }) }),
      down: ({Counter: state}) => ({ Counter: merge(state, { [props.key] : merge(state[props.key], { count: state[props.key].count - 1 }) }) })
    }
  },

How can we improve that?

Swizz commented 7 years ago

@jbucaran This is because, with this naive implementation, it is possible to give fractal state but not to work with. This need to be done by the core when merging partial state to the main state tree.

jorgebucaran commented 7 years ago

@Swizz Can you write the example again using pseudo-code and call this your proposal? 🙏

Swizz commented 7 years ago

Here we go : https://gist.github.com/Swizz/8437df6ac5ec4985e72e648d242f6a9f

This is a complexe but complete example. (no components list yet)

With component props initialisation, parent->child and child->parent communication, Fractal state, and namespacing.

andyrj commented 7 years ago

@zaceno I definitely don't want to take over anything, I was only chiming in with my 2 cents, I think all the feedback in this thread is useful.

@Swizz I like that gist, but I still feel like we should have some kind of helper or pattern to handle the key/id and at least a pattern for how the state could be partitioned for components. Maybe we even make the helper generic enough that it can allow users to describe their own lookup() for a components scoped state?

Edit: smarter people than I have solved what I was cludging in that code lol, https://gist.github.com/dtipson/37e47734f25f2b639779#file-make-lenses-js

jorgebucaran commented 7 years ago

@Swizz Why are you using emit? 🤔 😕

Swizz commented 7 years ago

@andyrj This will allow components to use the state out of their isolation. And I think this is not a good pattern.

@jbucaran child->parent comunication. The change event call the function on the onchange attribute.

We can also move into a parent property on state and actions, if you are more comfortable on this than props/events.

jorgebucaran commented 7 years ago

@Swizz Using emit like that is only optional right? 😅

EDIT

It is obviously optional. Anyway, events are currently not scoped like actions are, so users using emit like this need to be careful.

Would it be possible to make the emit passed into Counter to emit instead Counter.change?

So, scoped events. 😨

Swizz commented 7 years ago

@jbucaran Only useful if you want to use the events system to interact with your parent

jorgebucaran commented 7 years ago

@Swizz I personally find the way component is declared and then used is unintuitive. The declaration should be no different to how we declare actions or events.

Swizz commented 7 years ago

I use JSX because the API allow it. But, you can simply use function :

app({
  components: (state, actions, emit) => ({
    Counter: Counter({ 
      initialValue: 5,
      unit: {state.unit},
      onchange: value => emit('change', value)
    })
  })
})

or if you do not need anything from the state, actions, emit :

app({
  components: {
    Counter: Counter({ initialValue: 5, unit: 'monkeys' })
  }
})
jorgebucaran commented 7 years ago

@Swizz But why do you have to initialize the component when declaring it?

jorgebucaran commented 7 years ago

@Swizz So, why not just simply:

components: { Counter }
Swizz commented 7 years ago

Because I dont want either to do it into view. Like I said before, Components are in my mind super-powered isolated replicable mixins. Some bluprint with add features to the main app.

So, this initialisation is more than what to add to the State to initialize it. And also a way to isolate Component and add the ability to deal with an array of same components.

jorgebucaran commented 7 years ago

@Swizz So how do you pass props down to the component in the view?

Swizz commented 7 years ago

Why do you want to pass props into the view ?

In this idea, the view role is only to receive the component view vnode result and add it into its children array. The component hook is the only one responsible do deal with component logic.

View is to make a presentation of the state and add some hooks to deal with actions : no more.

jorgebucaran commented 7 years ago

Why do you want to pass props into the view ?

I think is is more intuitive to do it in the view. Let me see harder 🔎 🤓.

The component hook is the only one responsible do deal with component logic.

Sorry, what is the component hook?

Swizz commented 7 years ago

I dont know how to call these things ^^

app({
  components: ...
})

I think is is more intuitive to do it in the view

You, are the boss. But I am still against :boxing_glove: 😄

jorgebucaran commented 7 years ago

They are called properties.

The following code looks like when you instantiate a component in React.

  components: state => ({
    Counter: (
      <Counter initialValue={5}, unit={state.unit}, onchange={value => emit('change', value)}/>
    )
  }),

It's also not clear to me when is this function called and how many times is it called. I guess only once so it's intializing the view?

I think this can and will put off people, specially beginners or those coming from other libraries. I, myself, am very confused.

Swizz commented 7 years ago

I am not clear about that, in fact. Initialisation maybe. But what about conditional component or rendering a list of components according to an HTTP request result ?

jorgebucaran commented 7 years ago

@Swizz What about them? I don't see an issue with that because the wrapped component view is passed to the application view in the function like:

  view: (state, actions, { Counter, ... }) => ...