Closed zaceno closed 3 years ago
Here's how this could look like for Hyperapp.
First, define your button module:
import { h } from "hyperapp"
const Increment = state => state + 1
const Decrement = state => state - 1
export const init = 0
export const view = state =>
h("div", {}, [
h("button", { onclick: Decrement }, "-"),
h("div", {}, state),
h("button", { onclick: Increment }, "+")
])
In your main app, import the button and a new core function, e.g., map
, that transforms all messages generated inside Button.view
to something that makes sense for your main state.
import { h, app, map } from "hyperapp"
import * as Button from "./Button"
app({
init: {
button: Button.init()
},
view: state =>
h("div", {}, [
h("h1", {}, "Here's a button"),
map(state => ({ button: state }), Button.view(state.button))
]),
node: document.getElementById("app")
})
Another example, using the same module twice. I had to ask in the Elm-forum because it wasn't clear to me at the time. In hindsight, I realized I had misunderstood parts of how consuming a module works in elm and this cleared things up for me at least: https://discourse.elm-lang.org/t/using-multiple-instances-of-modules/4578
@zaceno How would you translate that into Hyperapp using what's currently proposed in https://github.com/jorgebucaran/hyperapp/issues/896#issuecomment-547380787?
@jorgebucaran It doesn't really change anything about your proposal. If I read it correctly (and understand what Elm's map does), it is basically:
map(actionTransformer, vnodes) -> mappedVnodes
Any onclick: Foo
in vnodes
will be transformed into onclick: MappedFoo
in mappedVnodes
, where MappedFoo
is produced from actionTransformer(Foo)
.
Right?
This approach requires no implementation in core and is so straightforward that I've certainly tried it before. But I can't recall what the problem was.
const namedScope = name =>
action =>
(state, payload) =>
({...state, [name]: action(state[name], payload)})
const Counter = scope => {
const init = 0
const incr = scope(state => state + 1)
const decr = scope(state => state - 1)
const view = state => (
<p>
<button onclick={decr}>-</button>
{state}
<button onclick={incr}>+</button>
</p>
)
return {init, view}
}
const Main = (() => {
const A = Counter(namedScope('A'))
const B = Counter(namedScope('B'))
app({
init: {A: A.init, B: B.init},
view: state => (
<body>
<h1>Here's two counters</h1>
{A.view(state.A)}
{B.view(state.B)}
</body>
),
node: document.body,
})
})()
EDIT IV: removed edits II - III , since I don't think they added much to the discussion and make you scroll a lot π
Modules Implementation (I haven't tried this, only for the discussion purposes) As usual, I tried to follow requirements:
Map. Since module actions are not exported directly, we can use vdom props to get the action references and decorate them, so map
function becomes simple as
import * as Button from 'button'
const decorate = (vdom, getter, setter) => ({
...vdom,
// recursive child decoration
childrens: vdom.childrens.map(node =>
decorate(node, getter, setter)
),
props: Object.keys(vdom.props).reduce((obj, key) => {
obj[key] = !key.startsWith('on')
? vdom.props[key]
// wrapping module action with custom 'filter'
: state => [
vdom.props[key],
function filter(param){
// and yes, filter which returns 'slice' function
// 'param' is also available and can be passed
return function slice(obj){
return obj ? setter(state, obj) : getter()
}
}]
return obj
}, {})
})
const map = (fn, getter, setter) =>
decorate(fn(getter()), getter, setter)
app({
init: { button: Button.init },
view: state => <div>
map(
Button.view,
() => state.button,
(state, button) => ({ ...state, button })
)
</div>
})
Button.
const increment = (state, slice) => {
// now you have plenty options here
// 1. you can call `slice()` to get sliced state (map getter)
// 2. you can `return slice(2)` to set sliced state (map setter)
// 3. you can dispatch sliced effect
// like `return [slice(1), effect()]` or even passing slice further
// like `return [slice(1), effect({ action: [ action, () => slice ] })]`
// 4. at the same time you can change global state if needed
// like `return { ...state, some: 'prop' }`
// 5. or dispatch any not mapped (external) action
// like `return [location, '/path']`
return slice(2)
}
export const init = 0
export const view = state => h('button', {
onclick: increment
}, state)
I haven't tried this on working example, but I can imagine this working. @jorgebucaran @zaceno any thoughts?
@sergey-shpak looks like a good possibility but how would you do it with payloads? And why the getter and setter? Why not just a single action transform?
@zaceno there are some pros and cons with this pattern, one of them that filters (~payloads~) inside of module should pass 'slice' function into action (also mentioned in comments but haven't implemented it yet - 'slice' filter also can pass param(payload) if any)
Since we can call sliced effects, getters and setters are used to return latest state, but I have to think about it more if I can simplify it
In general, main idea of this approach is quite simple - passing 'slicer' function as parameter into each module action, there are also some variations for dev userland
const action = (state, slicer) => {
const slice = slicer(state) // get sliced state
return slicer(state, 1) // set sliced state
}
@zaceno @sergey-shpak This feature must be in core.
@jorgebucaran
This feature must be in core.
I'm not disagreeing, but could you clarify: Do you mean it is something you don't want to leave up to userland, or are you aware of a technical reason why it must be in core?
Here's a possible implementation of map
outside core:
const map = (f, vnode) => ({
...vnode,
props: Object.entries(vnode.props)
.map(([key, val]) => [key, key[0] === 'o' && key[1] === 'n' ? f(val) : val])
.reduce((o, [key, val]) => ({...o, [key]: val}), {}),
children: vnode.children.map(child => map(f, child))
})
It will impact performance the more it is used, but I don't know of a way to avoid that by implementing it in core.
@sergey-shpak Is your solution really much different than https://github.com/jorgebucaran/hyperapp/issues/896#issuecomment-547662315 ? You make sure that actions get the slicer as an argument. I just make the slicer available in the entire scope of the module.
Also: what are the pros/cons of that approach?
@zaceno we are working on the same task some approaches can look similar, pros of https://github.com/jorgebucaran/hyperapp/issues/896#issuecomment-547857416 are:
cons:
@zaceno Do you mean it is something you don't want to leave up to userland, or are you aware of a technical reason why it must be in core?
Don't want to leave up to userland. No technical reason comes to mind, either. Well, maybe that it'd be hard to implement in userland?
@jorgebucaran π
Well, maybe that it'd be hard to implement in userland?
Doesn't look like it. At least, the code I pasted above seems to do the trick. See this codepen where I use it: https://codepen.io/zaceno/pen/gOOGzPr
@zaceno Can we match this API https://github.com/jorgebucaran/hyperapp/issues/896#issuecomment-547380787?
And does this solution look efficient to you?
Here is my idea how to make usage of modules for user less repetitive and error prune. I am picking code from @jorgebucaran as an example only. The same approach can be used for other proposals I can see here too, I think.
I do not propose any change to the implementation. Just to make it easier for the end-user and eliminate all code which can be auto-generated.
import { h, app } from "hyperapp"
import * as Button from "./Button"
const button = _mapping_("button", Button);
app({
init: {
...button.init() // returns { "button": 0 }
},
view: state =>
h("div", {}, [
h("h1", {}, "Here's a button"),
button.view // the same as `map(state => ({ button: state }), Button.view(state.button))`
]),
node: document.getElementById("app")
})
@jorgebucaran
Can we match this API #896 (comment)?
If you're referring to state => ({ button: state })
to describe the action transform then no, I don't think we can. I'm not even sure how to understand what that means. I thought maybe it was some kind of mistake, and you meant to describe an action transform.
At least how I understand Elm's map works is that it just applies a given function to all messages in the vdom. Nothing deeper or more magical. In theory that means you could (in hyperapp equivalent):
map(action => "foo", HomePage.view(state.page))
... and whenever anyone clicked a button in the home-page, the global state would be set to "foo" (and the app would crash, so you wouldn't do that of course... π ... but you could!)
Using my map implementation in the codepen above, the example could become:
import { h, app, map } from "hyperapp" // <--- map applies transform to all on* vals in a vnode
import * as Button from "./Button"
import slice from '@hyperapp/slice' // <--- make action transforms based on "slices"
app({
init: {
button: Button.init // <---- was Button.init() before. That was a mistake I assume
},
view: state =>
h("div", {}, [
h("h1", {}, "Here's a button"),
map(slice('button'), Button.view(state.button))
]),
node: document.getElementById("app")
})
I'd love to hear @SkaterDad's feedback on this one too.
Further on the topic of Elm's map, here's the example from their docs:
type Msg = Left | Right
view : model -> Html Msg
view model =
div []
[ map (\_ -> Left) (viewButton "Left")
, map (\_ -> Right) (viewButton "Right")
]
viewButton : String -> Html ()
viewButton name =
button [ onClick () ] [ text name ]
In hyperapp terms, this would look like:
const Left = state => ...
const Right = state => ...
const Button = name => h('button', {onclick: null}, name)
app({
...
view: state => h('div', {}, [
map(_ => Left, Button('Left')),
map(_ => Right, Button('Right')),
])
})
This is obviously a bit contrived, but I can think of a few other ways this could be useful than just for modularization.
@mshgh
...
h("h1", {}, "Here's a button"),
button.view // <--- don't you need state here?
...
@sergey-shpak Here's your (I think) version of slices using my map
implementation. As a demonstration of the power of allowing a generic action transform:
It's impressive that this can be achieved in userland. What about actions that return effects? Does your solution cover those? It does!
@zaceno looks good, and more important it works, I would suggest to use startsWith
method to simplify 'onEvent' check, but I'm quite happy with sergeySlice
:smile:
I can see two pitfalls here
Now that we're here, we might as well go for something like this:
https://codepen.io/jorgebucaran/pen/JjjOjwB?editors=0010
import { Counter } from "./Counter.js"
import { component } from "./component"
const [A, B] = component({ A: Counter, B: Counter })
app({
init: {
...A.init,
...B.init
},
view: state => (
<div>
<h1>Reusable counters:</h1>
<A.view state={state.A} />
<B.view state={state.B} />
</div>
),
node: document.getElementById("app")
})
@jorgebucaran @zaceno even though I proposed passing 'slice' into actions, and this pattern has some advantages, but I believe there is always a room for perfection, so I decided to take another try on this task... and now I'm quite happy with what I got
I call it callable state/context Main idea is passing component state with possibility to wrap component actions (that is why it's named callable). Implementation based on extending wrapper function with component state. It is quite simple and scalable at the same time.
Component (as simple as possible)
const multiply = (ctx, by) =>
({ ...ctx, value: ctx.value * by }) // or even `effect(ctx(action))`
const Button = {
init: {
value: 1
},
view: (ctx, child) =>
h('button', { onclick: [ctx(multiply), 3] },
`multiply state by 3 = ${ctx.value}`)
}
App
import { app, h, wire } from 'hyperapp'
app({
init: {
buttonA: Button.init,
buttonB: Button.init,
},
view: state => h('div', {}, [
wire(
Button.view,
(state) => state.buttonA,
(state, buttonA) => ({ ...state, buttonA })
)(state),
wire(
Button.view,
(state) => state.buttonB,
(state, buttonB) => ({ ...state, buttonB })
)(state)
]),
node: document.getElementById('app')
})
and wire
implementation (shipped as part of core, like Lazy, etc)
const wire = (fn, getter, setter) =>
state => fn(Object.assign(
function ctx(action){
return (state, param) =>
setter(state, action(ctx, param))
},
getter(state)
))
and working example https://codepen.io/sergey-shpak/pen/MWWvEPe
@jorgebucaran
What about actions that return effects? Does your solution cover those?
It does not. I've been trying to research and think about precisely that problem and not sure if I'm getting more or less confused ...
Looking at the the Elm realworld example here, it seems they use Cmd.map
in the main update, to do essentially the same thing as Html.map
but for commands. I'm not exactly sure how to translate that to Hyperapp though.
EDIT: PS: The docs strongly discourage the use of both Html.map
and Cmd.map
, yet Feldman uses them in the Main.elm
of his SPA-example, so... I don't know what's up with that.
About Cmd.map
the docs say:
This is very rarely useful in well-structured Elm code, so definitely read the section on structure in the guide before reaching for this!
... and the structure page links to the elm-spa-example wherein is found... Cmd.map
π
EDIT II: In trying to research this, I'm finding myself playing with Elm code again, and surprisingly I'm having a much less traumatizing an experience this time around!
@jorgebucaran @sergey-shpak Seeing your recent experiments, I need someone to remind me: what is the problem with this again: https://github.com/jorgebucaran/hyperapp/issues/896#issuecomment-547662315 ?
@zaceno there are no problems as for me, in general it works as expected. Imo, the question is how it works, how much difference it creates between usual and reusable components/modules, how much additional code user should write to achieve this and how readable it is, how to test 'mapped' actions, etc
@zaceno It does not. I've been trying to research and think about precisely that problem and not sure if I'm getting more or less confused...
Hmm, I should've tried it first. I just looked at your resolve
function and thought you were handling effects.
const resolve = (action, filter) =>
filter && !Array.isArray(action)
? [action, filter]
: !filter
? resolve(action, id)
: resolve(
action[0],
compose(
fix(action[1]),
filter
)
)
@jorgebucaran
Sorry I misread your question first and gave a non-sequitor response.
Here's the more correct response:
My map
impl doesn't care wether actions return effects or not, and doesn't need to. What to do if an action returns an effect should be handled by the user in the transform that goes in to map
. (Just like how it's up to a user how to handle ButtonMsg
in the update
of Main.elm
).
So, handling effect-returns should be done in my slice
implementation which is a separate thing (a helper for defining an action transform). I could do that but didn't feel it necessary since I don't use effects yet.
What I thought you meant was how to handle when effects dispatch actions. That is why I brought up Cmd.map
.
What I thought you meant was how to handle when effects dispatch actions. That is why I brought up Cmd.map.
We need to find a way to attach the transform information to actions, not vnode props to solve this.
@jorgebucaran
We need to find a way to attach the transform information to actions
I'm not 100% sure we want this forced on effects just because we used map
. If we want the same flexibility Elm has, I think we should leave it up to the user wether or not they want actions-dispatched-from-effects mapped with the same transform that was applied to the action that returned the effect.
The way we could leave it up to the user is to transform the effects inside the action transform passed to map. (Since technically it is the user's responsibility to define that transform, even though we may provide conventional helpers). Now that I think of it, that actually seems to parallell quite a lot with how Feldman uses the Cmd.map
in the update
of Main.elm
in his elm-spa-example.
... im going to go try that and see how it works.
... h("h1", {}, "Here's a button"), button.view // <--- don't you need state here? ...
@zaceno you are correct. I went too far with the simplification. Should have been
button.view(state)
@jorgebucaran @frenzzy et al
Here's a new version. I added a function mapEffect
corresponding to Cmd.map
in Elm, and I use it in my new and improved slice
mapper. Now dispatching mapped effects works as well. Have a look!
@mshgh I went too far with the simplification. Should have been button.view(state)
That's not enough, you need to map the view to the "slice". Like this https://github.com/jorgebucaran/hyperapp/issues/896#issuecomment-548152322
@zaceno Have a look!
Done. It works! Very nice. Okay, next is:
1) How do we bring this into core? Can it be implemented in less code? 2) How is the API going to look? 3) Subscriptions?
@jorgebucaran
- How do we bring this into core? Can it be implemented in less code?
In less code, sure maybe. But I'm not sure any of this needs to be in in the actual src/index.js
- rather I feel like maybe it belongs in a separate @hyperapp/vdom
(with Lazy
perhaps) or @hyperapp/module
or something.
If we really want it to be less code, I suppose there is some possibility of code reuse in src/index.js
but I don't see any necessity or apparent benefit of deeper integration with the main app
engine.
- How is the API going to look?
Well, my thought is: I think it's important that map
or eventMap
or vdomMap
or whatever we end up calling it, completely unopinionated about how to transform actions. It just takes a transform as an argument.
That means it is up to users to define their own transforms, which on the other hand can be kind of hard. So I think it makes sense to export something like my slice
transform as a helper, since that is presumably a common need. Akin to how we export official effects / subs.
This all means I think we could/should have a @hyperapp/something
which exports at least three functions:
mapEvents: (actionTransform, vdom) -> vdom
mapEffect: (actionTransform, effect) -> effect
slice: string -> actionTransform
- Subscriptions?
Good question! π ...next order of business, I guess!
@zaceno It's built into Elm, though!
@jorgebucaran
It's built into Elm, though!
Right. Well it's exported from a non-core package, but that export relies on support in the virtual-dom package. I wasn't able to follow the implementation entirely, but it looks like we could/should have a "map" or "tag" property on vnodes, which we, on render, propagate down through the vnode tree.
Also, I just realized mapped subscriptions will require built in support as well, because applying transforms to them directly will make them new instances each render.
@mshgh I went too far with the simplification. Should have been button.view(state)
That's not enough, you need to map the view to the "slice". Like this #896 (comment)
@jorgebucaran you do not need to. It was actually part of my point how to make usage less error-prune. You are referring to your code <A.view state={state.A} />
and I say this can be simplified to <A.view state={state} />
provided you never want to do something like <A.view state={state.B} />
Checking your codepen line 39 should change from view: ({ state }) => map(slice, view(state))
to view: ({ state }) => map(slice, view(state[key]))
- tried, works.
@zaceno It's true that Elm uses an external package for this! Perhaps we should export Lazy
and map
(name still subject to change) into @hyperapp/html
.
@mshgh I get your point, and we could get away with it in Hyperapp, because unlike Elm, we're not mapping typed messages, and just composing functions, passing data around. Still, I prefer state.A
or state.B
because explicitness is good.
@jorgebucaran sounds good! FYI Iβm currently working on integrating support in core. Itβs tricky but doable and will be more efficient than my userland solution.
Made a PR! Here's a demo of it working: https://codesandbox.io/s/hyperapp-action-mapping-demo-z3ydz
New example demonstrating how child-to-parent communication could work, using (what I think is) our equivalent of the translator pattern
Here's the sandbox: https://codesandbox.io/s/hyperapp-action-mapping-with-translator-pattern-demo-cudh3
Note this changes nothing about the necessary plumbing needed in hyperapp (in the PR above). I just made my slice
function a little more elaborate.
@zaceno I took your first demo and added one more layer to support components the way I like :-) see this. I didn't change anything just re-used your slice*() methods and turned components (Counter and Main) into objects. I've noticed your other demo only now - didn't see it yet. P.S. I do not know how to use your functions to support actions, so I added my own way which is probably too simplistic...
@zaceno We might never agree on an API for modules, so what is the minimal Hyperapp "primitive" I can give you to help you assemble your "slice.js" module the way you want?
@jorgebucaran
so what is the minimal Hyperapp "primitive" I can give you to help you assemble your "slice.js" module the way you want?
The absolute bare minimum of help would be to merge my PR -- that was the idea of the PR. To just be the plumbing. You could even skip the mapEvents
, mapEffect
, mapSubs
functions if you want -- but then users would have to rely on knowing the "secret" of how to map actions (add an actionMap
prop to vnode, or add a third element to effect-tuples)
@mshgh Cool, but there seems to be some mistake since only A is affected by actions (and both counters render A) (on my phone atm so I couldnβt easily check the error)
Interesting. Works fine for me. Both updates to A as well to B including the delayed reset for decrements. Chrome 77.0.3865.120 Win10
@mshgh You're right it does work -- must have just been my phone. Safari has been weird lately
Haven't had much time to work w/ Hyperapp lately and review this.
I tend to have a fairly flat state tree w/ V2, and pass in a name/key for the component state. It's simple and gets the job done. You have to name the state no matter what, and this is only a little extra work. My production apps are still in V1 due to time constraints, so this is just experimental.
I kind of like the idea that every action can see the full app state. You never know when that will come in handy, so I'm hesitant to create isolated components as I explore V2.
Tedious example code below:
I actually really like what @zaceno posted here for simple components. It's basically what I do, but better abstracted. https://github.com/jorgebucaran/hyperapp/issues/896#issuecomment-547662315
πββ Check out the current proposal here.
Consider this minimal example of a modular Elm app (copied from here):
Button.elm
```elm module Button exposing (Model, Msg(..), init, update, view) import Html exposing (Html, button, div, h1, text) import Html.Events exposing (onClick) -- MODEL type alias Model = Int init : Model init = 0 -- UPDATE type Msg = Increment | Decrement update : Msg -> Model -> Model update msg model = case Debug.log "Button.update msg: " msg of Increment -> model + 1 Decrement -> model - 1 view : Model -> Html Msg view model = div [] [ button [ onClick Decrement ] [ text "-" ] , div [] [ text (String.fromInt model) ] , button [ onClick Increment ] [ text "+" ] ] ```Main.elm
```elm type alias Model = { button : Button.Model } init : Model init = { button = Button.init } -- UPDATE type Msg = ButtonMsg Button.Msg update : Msg -> Model -> Model update msg model = case Debug.log "update msg" msg of ButtonMsg buttonMsg -> { model | button = Button.update buttonMsg model.button } -- VIEW view : Model -> Html Msg view model = div [] [ h1 [] [ text "elm button example" ] , map ButtonMsg (Button.view model.button) ] ```Notice two things:
A)
Increment
andDecrement
are completely unaware of the shape of the global app state. Their definitions are completely self contained.B)
Main.elm
never once explicitly referencesIncrement
orDecrement
.As far as I've been able to tell, this is not possible to achieve using Hyperapp 2. You can have A or B but not both. This has frightening implications for large scale apps.
I would like to see a convention, add-on library or core change -- or some combination of all three -- which enabled us to replicate the example above in Hyperapp v2.
That essentially means being able to use a module which defines its actions in a self-contained, app-agnostic way, without being required to export them. They should not need to be explicitly referenced anywhere else.
Additionally, whatever we come up with, I would also want it to work in a recursive fashion, that is to say: a module can use a module which uses modules ...
EDIT: Also, of course, this shouldn't just apply to actions dispatched from views. It should be the same for actions dispatched from Effects (whatever action initiated the effect), and subscriptions.