jorgebucaran / hyperapp

1kB-ish JavaScript framework for building hypertext applications
MIT License
19.04k stars 779 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

Yesterday I've created partially (see issues below) working solution without components: []. You can just import component and immediately use it but I don't know how to track components without publicly exposed identification. I need some kind of hidden tracking.

Here is actual and working variant of writing apps with this version:

Counter example

with initial values Below is without initial values

app.js

import { h } from 'hyperapp'
import Counter from './counter'
import Logger from './logger'

app({
  state: {
    who: 'world',
    // variant without initial values:
    countersId: [0, 1, 2]  // only ids of counters
  },

  actions: {
    addCounter: (state) => {
      const countersId = state.countersId
      counters.push(countersId[countersId.length - 1] + 1 })
      return { countersId }
    },
    removeCounter: (state, actions, index) => {
      const countersId = state.countersId
      countersId.splice(index, 1)
      return { countersId }
    }
  },

  view: (state, actions) => ( // arguments: (state, actions, props, children)
    <div>
      <div>Hello {state.who}!</div>
      <button onclick={actions.addCounter}>Add counter</button>

      {state.countersId.map((id, index) => (
        <div key={id}>
          <Counter id={id} />
          <button onclick={() => actions.removeCounter(index)}>Delete</button>
        </div>
      ))}

    </div>
  ),

  mixins: [Logger]
})

counter.js

import { h } from 'hyperapp'

export default {
  name: 'Counter', 

  state: { // you don't need functional state but you can use POJO or primitive
    sum: 0,
    number: 1
  },

  actions: {
    increase: (state) => ({
      sum: state.sum + state.number,
    })
  },

  view: (state, actions, props, children) => (
    <div>
      <div>Counter: {state.sum}</div>
      <button onclick={actions.increase}>
        Add {state.number}
      </button>
    </div>
  )
}

Gif example

example02

In the example above you can see app.state with all "local" states of "stateful" components under @Counter: {}. Every component instance is stored under its ID. It has single source of truth.

My goals

How did I achieve this?

In a nutshell I've achieved this by creating and sharing outerEvents between app() and h(). When you call h(tag, data) it will verify if tag is an object and if it is, it calls `outerEvents.get("child", ({ component: tag, data }))` which will get response from app() where is outerEvents.set("child", ({ component, data }) => ...) and here is look for existing instance by ID in app.state[componentName][instanceId] or it creates new one. Actions are almost the same as in the first version (component's actions are connected to app.actions with first loading of component). ...It's a little bit less code than in my previous version but I've found out that I can't use components without ID (that problem is even with first version).

Known issues (TODOs)

P.S.: Bear in mind that I'm newbie in programming (I started one year ago) so if you find this approach interesting I would be thankful for any help or guidance! 🙏 👍 (sorry for my poor English)

Swizz commented 7 years ago

This one looks too magical. For someone like me who is new into the Hyperapp community.

I am pretty in love about the fractal idea for state/actions. The main concern is to handle list of same component with their own state.

MatejBransky commented 7 years ago

@Swizz Thanks for your answer. 👍 But I think that only magic is ID part (we need some identification for instances of component if we want keep single source of truth but maybe we can hide it somehow). If you look closer. You will see that component counter (POJO) is "stateful component". So as user you write components almost the same as app() (you work with component's state and actions just like in stateful components). Only difference is adding name: key (maybe we find some solution without naming) and props with children but they are fundamental for component based approach (you need somehow process inserted properties and children from parent). But these concerns can be easily suppressed in docs with proper guidance.

jorgebucaran commented 7 years ago

@MatejMazur The magical part is what you are doing inside h to make this miracle happen. 💯

The reason it is magical is that custom tags return a virtual node tree, not an object. So, although I like this I also agree with @Swizz.

ids

I don't think we need to get rid of ids. The id is not really the core of how components work, but rather how index.js keeps track of them. Let's keep thinking. I've updated my example to fill in the missing code.

jorgebucaran commented 7 years ago

@MatejMazur How would you pass initial values / props to a component in your example?

jamen commented 7 years ago

Awesome @MatejMazur. Looks like a good start. A clean solution to me.

Like @jbucaran, I'm wondering how you get more control over the props and children. I do see you pass them through view, but I think you would need more "open access" over them for all fields, because it is your only way to get global state and global actions.

What if you did the exact same thing, but wrapped it in a function that was passed (props, children)?:

const Component = (props, children) => ({
  // ...
})

Instead of passing them through every single avenue like with state/actions. Do you think this would work?

leeoniya commented 7 years ago

What if you did the exact same thing, but wrapped it in a function that was passed (props, children)?:

FWIW, we recently had the same discussion in domvm [1], since it works this way. the only issue is that if either of those values ever get replaced by a parent, they become stale in the closure.

[1] https://github.com/leeoniya/domvm/issues/147#issuecomment-305987998

MatejBransky commented 7 years ago

@jbucaran How would you pass initial values / props to a component in your example?

state: {
...,
counters: [
{ // props are just everything inserted to custom tag
someValue: 0, 
someText: 'foo',
initial: 1 // I want to keep eye specifically on "initial" because everytime you update it, 
//  you'll get to the problem with previous state of component...
//  what to do in such situation? ..reset state?
// I think that it's important to keep such important value separately 
// from props inserted only to view part of component which don't 
// affect component state but hey..we can change that :-D
}
]
}
...
{state.counters.map((data, index) => (
<Counter {...data} /> // variant A
<Counter 
value={data.someValue} 
text={data.someText} 
initial={data.initial} /> // variant B
))}

@jamen

Like @jbucaran, I'm wondering how you get more control over the props and children. I do see you pass them through view, but I think you would need more "open access" over them for all fields, because it is your only way to get global state and global actions.

Send example of some situation where you need more "open access". Now the props.initial is key for more open access.

What if you did the exact same thing, but wrapped it in a function that was passed (props, children)?

I think that it's important to keep ordinary props separately from component state and actions because if you want somehow to interfere to component actions then why would you do that? You can change behavior of component actions by initial value (it can be str, num, obj, arr) inserted in component state then you can use it through component state in args of action. The same way as you do these things in app actions.

zaceno commented 7 years ago

FWIW, with one really tiny change in app.js + a simple function that can live in userland, it becomes possible to write code like @MatejMazur 's example above. See PR https://github.com/hyperapp/hyperapp/pull/241

... of course this approach breaks the "single source of truth" notion. In my setup each component is it's own app with it's own state. Communication between parents need to happen via props (pass notification actions down to children).

So it's not the perfect solution. (For example, time-travel debugging a bunch of apps that can come and go through the life of an app seems pretty tough, if not impossible) But I thought it relevant to mention in this thread anyway.

naugtur commented 7 years ago

Hi everyone. I saw this thread after I already had some expectations as to how components would work and I thought I'd show you what they were.

In a nutshell:

See #245

jorgebucaran commented 7 years ago

@naugtur Thanks for the feedback. This thread is to speculate and discuss how the component API could look like.

keep one state and one source of actions

Absolutely! 💯

Swizz commented 7 years ago

My contribution to the discussion, with a little bit of my Cycle.js thoughts (Onion, Isolate, etc...) Like I said in favor of Fragmented State and Actions for a Single Source of Truth.

Here is a working example on Hyperapp 0.9.3 :tada:

Counter.js

const Counter = (props, children) => () => ({
  state: {
    Counter: {
      [props.key] : {
        count: 0
      }
    }
  },

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

  view: (state, actions) => 
    h('div', { class: 'counter' }, [
      h('button', { onclick: actions.Counter.up }, '+'),
      h('span', null, state.Counter[props.key].count),
      h('button', { onclick: actions.Counter.down }, '-')
    ])
})

index.js

app({
  view: (state, actions) =>
    h('div', null, [
      Counter({ key: 1337 })().view(state, actions)
    ]),

   mixins: [Counter({ key: 1337 })]
})

As you see, Mixins allow already a lot of things regarding fractal state and actions. So, I vote in favor of a Component api point :

app({
 //...
 depends: [Counter({ key: 1337 })]
 //...
})

That will be just a super powered mixins that will do the following stuffs :

Swizz commented 7 years ago

A little bit more neat with JSX. :+1: I think I will battletest this on the app I am working on. (With some helpers for actions to avoid this long-long merge chain)

Counter.js

const Counter = (props, children) => () => ({
  state: {
    Counter: {
      [props.key] : {
        count: 0
      }
    }
  },

  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 }) }) })
    }
  },

  view: ({[props.key] : state}, actions) => (
    <div class={"counter"}>
      <button onclick={actions.up} />
      <span>{state.count}</span>
      <button onclick={actions.down} />
    </div>
  )
})

index.js

app({
  view: (state, actions) =>
    h('div', null, [
      (<Counter key={1337}/>).view(state.Counter, actions.Counter),
      (<Counter key={8888}/>).view(state.Counter, actions.Counter)
    ]),

   mixins: [<Counter key={1337}/>, <Counter key={8888}/>]
})
Dohxis commented 7 years ago

I would like to jump into this conversation by saying that I really like where this little framework is going. I have been using Elm for some time now and the best thing for me is that Elm is elegant and simple to use. Its syntactically beautiful. As Hyperapp is trying to be Elm-like I guess seeing those objects nesting one into another just tooks all the beauty. Why we cannot use new Javascript features to have beautiful looking framework. In my opinion, getting things out of those objects and at least letting components have a nice syntax would be a great decision. I have been working on my app and wrote some helper functions and classes to help me work with it. Here is the simple component which I thing looks simple and easy understand what its doing by just looking at it.

class Counter extends Component {

    constructor(props){
        super(props);
    }

    render(){
        return (
            <div>
                <button onclick={this.actions.add}>+</button>
                {this.state.num}
                <button onclick={this.actions.sub}>-</button>
            </div>
        );
    }

};

Yes it looks like `React but its the syntax people have been using for a long time right now, and they like it. Projects always keeps growing and having a nice way to write components will make our lives just easier.

This is only my opinion. Keep up with a great work :+1:

naugtur commented 7 years ago

Classes bring side effects and all the mess were avoiding with functional programming. It's all about references to functions and the main structure of the app could be declared once while each component is a pure function. That's what came with elm, classes would hurt that.

jorgebucaran commented 7 years ago

@Dohxis We want a single state architecture. So, using this goes against this very principle. :)

Dohxis commented 7 years ago

I can understand that classes would bring unwanted things, did not experienced on my small app though. But I am keeping single state and single actions. They are passed as props.

Swizz commented 7 years ago

I was thinking about how to implement interactive doc, when I was punch by an awesome thing : Hyperapp is already ready for HTML component by design

<body>
  <app-counter></app-counter>
  <app-counter></app-counter>
  <app-counter></app-counter>
</body>
const { h, app } = hyperapp

const Counter = (props, children, element=document.body) => ({
  root: element,
  state: {
    value: props.value
  },
  view: ({ value }, { inc, dec }) =>
    h('div', { class: 'counter' }, [
      h('button', { onclick: inc }, '+'),
      h('span', null, value),
      h('button', { onclick: dec }, '-')
    ]),
  actions: {
    inc: state => ({ value: state.value + 1 }),
    dec: state => ({ value: state.value - 1 })
  }
})

document.querySelectorAll('app-counter').forEach((elem) => {
  app(Counter({value: 0}, [], elem));
})

This is not really great to see ; but it works well :

app({
  state: {
    title: "Hello."
  },
  view: (state, actions) =>
    h('main', null, [
      h('h1', null, state.title),
      h('app-counter', null)
     ]),
  events: {
    render(state, actions, view, emit) {
      document.querySelectorAll('app-counter').forEach((elem) => {
        app(Counter({value: 0}, [], elem));
      })
    }
  }
})

PS : Im doing a lot of experiments with hyperapp to use it into a large scale project, if you find my every day comments here a lot annoying, do not hesitate to ask me to stfu.

jorgebucaran commented 7 years ago

@Swizz Please keep them coming! It adds a lot to the discussion. 🙏

nitin42 commented 7 years ago

We could also think about adding Dynamic Components, using the same mount point and switching between different components by binding them to an attribute like Vue.js does.

Example

app({
  state: {
    title: "Hello."
  },
  data: {
    currentView: 'home'
  },
  components: {
    home: { doSomethingHere },
    About: { doSomethingHere },
    Contact: { doSomethingHere } 
  }
})

So depending upon the UI logic we could switch between different component using a wrapper or a method.

This would be useful in doing transitions (transitioning between the components or elements) and other stuff.

Swizz commented 7 years ago

@nitin42 Im definitely going to work on this one : https://github.com/hyperapp/hyperapp/issues/238#issuecomment-310999839

This is best one : Single state and actions tree "object" merged with the superpower of mixins. So, all the code base is already here.

nitin42 commented 7 years ago

Great!

jorgebucaran commented 7 years ago

Very interesting idea @Swizz. Can you give it another shot at explaining the implementation part?

What is depends? And how to avoid repetition?

Swizz commented 7 years ago

My english is not really good yet, but I will try to be as clear as possible.

depends is just another word for components but as you seen, if you want two instances of a counter your main app need to depends on two instances of a counter.

I dont know really why, but I am against the fact to declare component into the view. Components need to be instanciated at high level, view() job is only to make a presentation of the state, isnt it ?

But we need a developer experience when using component by that way will be also possible app(Component()). So this is why I am in favor of Fragmented state.

The hyperapp maintain a single source of truth as a single state object with a single object of mutators (actions). Through a namespace system. But dynamic because we can handle a lot of instance of a component with their own state substree.

The former example is working with the current implementation with mixins but this is not really neat by using a lot of tricks.

MatejBransky commented 7 years ago

@Swizz basically came up with the same approach as I did in Slack channel. But he knows how to achieve that with less intervention to core. It's not bad, but we want to avoid another boilerplate so we can use the components with the same simplicity as the application itself.

jorgebucaran commented 7 years ago

@MatejMazur @Swizz @jamen @everyone

How does everyone feel about this approach?

app.js

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

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

counter.js

import { h, component } from "hyperapp"

export default component(({ initialValue }) => ({
  state: initialValue,
  view: (state, { up, down }) =>
    <div>
      <h1>{state}</h1>
      <button onclick={up}>+</button>
      <button onclick={down}>-</button>
    </div>,
  actions: {
    up: state => state + 1,
    down: state => state - 1
  }
}))
Swizz commented 7 years ago

I am still against the fact to instantiate components from view function. (but you are the director)

There will be a single state/actions object with subtree/namespace common to the whole app or a component will be a subset of an app capable to live into a main app view ?

How about parent to child communication ? And brother to brother ? Keeping a single state not one by component could facilitate this one.

jorgebucaran commented 7 years ago

I am still against the fact to instantiate components from view function.

What do you mean by "instantiate components from view function"?

There will be a single state/actions object with subtree/namespace common to the whole app or a component will be a subset of an app capable to live into a main app view ?

Sub-tree, single state tree.

Swizz commented 7 years ago

What do you mean by "instantiate components from view function"?

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

Adding a components props to the app() is IMHO a better pattern. The view() is only to deal with vnode. I think giving it the role to ensure components usage is more than excepted.

A components props, could tell clearly "I want to use this component, so add a dynamic segment into the state tree and give me access to its mutators" and component side being an app or a component will be transparent as it will be work on its own mutators and state. As a fractal.

Same thougths than Cyclejs and Onionify but without FPR and more as Elm Architecture.

Swizz commented 7 years ago

OR.. We can discourage the use of stateful components. To maintain only a single state as source of truth.

jorgebucaran commented 7 years ago

@Swizz There are no stateful components anywhere.

EDIT: nor hyperapp will ever support stateful components.

jorgebucaran commented 7 years ago

A components props, could tell clearly "I want to use this component, so add a dynamic segment into the state tree and give me access to its mutators" and component side being an app or a component will be transparent as it will be work on its own mutators and state. As a fractal.

Don't like it so much, but I have to agree with you here. 👍

MatejBransky commented 7 years ago

So back to this?

app({
  state: ...,
  actions: ...,
  view: (state, actions, components) => (...),
  components: [Counter,...],
  mixins: [...]
})
Swizz commented 7 years ago

Not yet. In my implementation, if you want multiple counters, you will need to use the following :

app({
  components: [Counter(1), Counter(2), ...]
})

But how to add another counter from actions ?

Component is just a way to add a subset to an app but actually we need to think about how to interact with a fractal of the state if multiple "instances" of a component exist..

MatejBransky commented 7 years ago

@Swizz I solved this before in Slack channel. You don't need init instances in components. You need only component and then you use id from array of countersId (see countersId in comment at the top). That will create instance under specific id.

jorgebucaran commented 7 years ago

@MatejMazur @Swizz So, I'm thinking like this. 🤔

app.js

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

app({
  state: {
    title: "Hello."
  },
  view: ({ title }, actions) =>
    <main>
      <h1>{state.title}</h1>
      <Counter initialValue={5} />
    </main>,
  components: [Counter]
})

counter.js

import { h } from "hyperapp"

export default ({ initialValue }, children) => ({
  state: initialValue,
  view: (state, { up, down }) =>
    <div>
      <h1>{state}</h1>
      <button onclick={up}>+</button>
      <button onclick={down}>-</button>
    </div>,
  actions: {
    up: state => state + 1,
    down: state => state - 1
  }
})
naugtur commented 7 years ago

@jbucaran re: your proposal - I don't like the fact that the component has state that's separate.

That's why in my fork I proposed a version that kept state in the main app and passed down the actions which are bound to the main state anyway.

MatejBransky commented 7 years ago

@jbucaran But how do you connect to instance part in global state? You need some kind of id. Because if you don't have id, you can't update state of instance.

jorgebucaran commented 7 years ago

@naugtur The component's state is not separate. It looks as if it was separate, but it is not.

Neither was that the case on my first approach. But it was rather "magical", as it wasn't obvious that Counter's state and actions were to be merged with the app's intance internal state, actions.

Now, with this approach, you pass Counter into the app via components:, which makes it evident.

Does that make sense? :)

MatejBransky commented 7 years ago

@jbucaran And when you drop the third argument from the view, you actually call the Counter function again without any dependency to Counter in components prop. That's weird.

jorgebucaran commented 7 years ago

@MatejMazur Whoops, you are right. We'll need the component function after all to wrap Counter.

Swizz commented 7 years ago

Component seem to have its own state, but component "state" will be merge to the app state. Like it does for mixins. In my mind, component is just a superpowered mixin that just add things into a namespace.

@MatejMazur @jbucaran ?

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

app({
  state: {
    title: "Hello."
  },
  view: ({ title }, actions) =>
    <main>
      <h1>{state.title}</h1>
      <Counter key={1234} />
    </main>,
  components: state => [Counter({ initialValue: 5, key: 1234 })]
})
import { h, app } from "hyperapp"
import Counter from "./counter"

app({
  state: {
    title: "Hello."
  },
  view: ({ title }, actions, components) =>
    <main>
      <h1>{state.title}</h1>
      {components.map((Component, /* key */) =>
        <Component /* key={key} */ />
      )}
    </main>,
  components: state => [Counter({ initialValue: 5, key: 1234 })]
})
MatejBransky commented 7 years ago

@jbucaran And we're back again. 😄

jorgebucaran commented 7 years ago

@MatejMazur 🤣

naugtur commented 7 years ago

How about this: (should be doable based on my experience to date)

app.js

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

app({
  state: {
    title: "Hello.",
    counterItem: 5
  },
  view: ({ title }, actions) =>
    <main>
      <h1>{state.title}</h1>
      <Counter />
    </main>,
  components: [Counter]
})

counter.js

import { h } from "hyperapp"

export default (props, children) => ({
  view: (state, { up, down }) =>
    <div>
      <h1>{state}</h1>
      <button onclick={up}>+</button>
      <button onclick={down}>-</button>
    </div>,
  stateRoot: "counterItem",
  actions: {
    up: state => state + 1,
    down: state => state - 1
  }
})

Names of things are not part of the suggestion, stateRoot is a lame name, but easy to understand the concept here.

I'm not sure if the decision about state root should be made inside or outside the component.

MatejBransky commented 7 years ago

@naugtur could you please correct format of code?

naugtur commented 7 years ago

I did right away, but github doesn't refresh automatically if it's not a new post

jorgebucaran commented 7 years ago

This one is tough, but we'll make it through. I am sure. I've noticed a few more mistakes above, so I'll take another approach to brainstorming this by creating a list of facts.

naugtur commented 7 years ago

I'd add to this a suggestion to define state in one place, outside components. That's what redux got its adoption from and imho it makes it easier to explain or understand the app.

If I can push it even further, I'd suggest to only have components as pure functions that get a reference to a part of state and some actions. That'd eliminate the need to merge anything on app start and would make component scope (by scope I mean part of state they access) independent of component parent-child relationship.

jorgebucaran commented 7 years ago

@naugtur Can you post an example with fake code? 🤔