Simplify component constructor to function that returns either a view function or a vnode tree #2917
Replies: 25 comments
-
As the author of mithril-cc, naturally I'm excited about this 😄 Regarding For comparison, react hooks don't have this problem since the component function gets invoked every redraw. Not that it's a better solution by any means, as hooks create many more subtle problems when it comes to closures, but the point is react users never have to think about the current state of their props. With mithril-cc I solved this problem by providing |
Beta Was this translation helpful? Give feedback.
-
@gilbert Might not be a bad idea, but that does come with some performance cost (V8 doesn't inline closure accesses properly for one, and you'd have to memoize it for non-closure components), and it's also a bit awkward IMHO. We can use documentation to warn people of this gotcha, though, and it only applies for stateful components. |
Beta Was this translation helpful? Give feedback.
-
Ah right, since both versions are functions, mithril doesn't know what version your component constructor is until after it runs once, which poses a problem. |
Beta Was this translation helpful? Give feedback.
-
And also I'm not a fan of presenting two different ways of getting attributes for two different component types. Sounds ripe for confusion IMHO. |
Beta Was this translation helpful? Give feedback.
-
The stale attrs problem is more confusing IMO, as it's much harder to document (not to mention its inelegant workarounds). But it seems there's not much mithril can do here, other than assuming function components will always return closures, which... isn't terrible, but it is more opinionated. I'll probably have to just stick with using mithril-cc. Speaking of opinionated, would this change mean mithril is dropping support for class components? |
Beta Was this translation helpful? Give feedback.
-
The problem is really that there's no nice alternative that doesn't have major pitfalls. Option 1: my proposal // Oops, shadowed
function Foo(attrs) {
return () => m("div", {class: attrs.class})
} Option 2: use a function reference (your suggestion in Gitter) // Works, but isn't very optimizable by engines
function Foo(attrs) {
return () => m("div", {class: attrs().class})
} Option 3: use an instance property (what React did) // Variant 1: class
// Property updated by the renderer before each update
m.Component = function (attrs) { this.attrs = attrs }
// Oops: `this` isn't the class instance
class Foo extends m.Component {
view() { return m("button", {onclick() { Model.incrementItem(this.attrs.id) }}) }
}
// Variant 2: `this`-driven
// Oops: `this` isn't the class instance
function Foo() {
return () => m("button", {onclick() { Model.incrementItem(this.attrs.id) }})
} Option 4: use an object reference // Property updated by the renderer before each update
// Gets awkward in a hurry, but if we shorten it to `i.attrs.class`, it might be
// workable.
function Foo(context) {
return () => m("div", {class: context.attrs.class})
} Option 5: use hooks // Either it ends up rigid on diffing or bloats the bundle significantly, and it's
// not concise at all in practice. It's also prone to leaving you with stale
// closures.
function Counter() {
const [count, setCount] = slot(0)
return [
m(".display", count),
m("button", {onclick() { setCount(count + 1) }}, count)
]
} Option 6: use streams // This is actually very involved to wire up correctly and involves a lot of edge
// cases, just FYI. I've looked into this model already, and we'd have to rewrite
// our renderer for it.
function Foo(attrs$) {
return attrs$.map(attrs => m("div", {class: attrs.class}))
} Here's my analysis of each:
Of all these, I'm leaning towards option 4. @gilbert @barneycarroll What do you think?
Yes. |
Beta Was this translation helpful? Give feedback.
-
I would (again) suggest another version, which I now use for quite a while with a small helper ( function withState(getView) {
const comp = {
oninit: ({ state, attrs }) => (state.viewFn = getView(...attrs.args)),
view: ({ state, attrs }) => state.viewFn(...attrs.args),
}
return (...args) => m(comp, { args })
} Than, I only use "functional" components (aka view-functions), that return trees. If I need state I create a closure that returns a view function just like your second example. I pack this up with my helper to mark is a component. The result is again a view function. This way it's transparent for the consumer, if the component is just a function or a component. This way you can easy switch from one to the other without changing the call-site. const compView = withState((...initialArgs) => (...args) => tree)
// usage
compView(...args) // returns tree I think this would also go great in conjunction with |
Beta Was this translation helpful? Give feedback.
-
Another con of Option 2 is the dilemma between only providing the Sleeping a bit on Option 4, I'm starting to like it. Aside from the benefits you list, On the other hand, @StephanHoyer's example makes me wonder, does mithril need to handle state at all? Could it only worry about component identity and handoff state approaches to the user? Perhaps with a few "canonical" helpers. |
Beta Was this translation helpful? Give feedback.
-
I'm a bit unsure of how to parse this, could you elaborate? |
Beta Was this translation helpful? Give feedback.
-
Code examples can use destructuring syntax by now, and context has a common abbreviation in |
Beta Was this translation helpful? Give feedback.
-
@orbitbot True, and in practice I anticipate a lot of destructuring with it. This is more in reference to keeping the most recent value at all times. |
Beta Was this translation helpful? Give feedback.
-
@StephanHoyer Okay, that makes me think an option 7 where we just use |
Beta Was this translation helpful? Give feedback.
-
You have to be careful where you destructure: // Good
function Foo(ctx) {
return () => {
let { attrs } = ctx
return m("div", {class: attrs.class})
}
}
// Bad, attrs becomes stale
function Foo({ attrs }) {
return () => m("div", {class: attrs.class})
} I would add this as a con to 4. |
Beta Was this translation helpful? Give feedback.
-
Isn't this essentially the exact same thing as with the current syntax, apart from actually being able to access the correct
Are these things that should actually be provided by the framework to each component, as a prima vista it feels a bit odd to have these "global methods" re-referred in individual components? Also this could be achieved in userland? It may well be that I'm not immediately recognising the opportunities this affordance would enable, though. |
Beta Was this translation helpful? Give feedback.
-
If we go the context route, I'll also be pushing to add function Counter(i) {
const state = i.link(() => ({count: i.attrs.initial ?? 0})
return [
m(".display", state.count),
m("button", {onclick() { state.count++ }}, count)
]
} Stale attributes is still possible (JS doesn't have a way to access by reference except via object properties and closures), but it'll be much less counterintuitive why. |
Beta Was this translation helpful? Give feedback.
-
We could just have
|
Beta Was this translation helpful? Give feedback.
-
I dislike this style of state as it quickly gets clumsy if you need one state property to reference another. For example: function Counter(ctx) {
const state = ctx.link(() => {
const $count = m.stream(ctx.attrs.initial ?? 0)
const $changeCount = m.stream(-1)
$count.map(() => $changeCount($changeCount() + 1))
return { $count, $changeCount } // :(
})
return [
m(".display", state.$count()),
m("button", {onclick() { state.$count(state.$count() + 1) }}, state.$count())
]
} And that's just with two properties. This object-return repetition makes it slightly worse than a class syntax, and without the convenience of instance methods: Class syntax equivalentclass Counter {
constructor({ attrs }) {
this.$count = m.stream(ctx.attrs.initial ?? 0)
this.$changeCount = m.stream(-1)
this.$count.map(() => this.$changeCount(this.$changeCount() + 1))
}
view = () => [
m(".display", this.$count()),
m("button", {onclick() { this.$count(this.$count() + 1) }}, this.$count())
]
} Of course, the closure component style is much nicer. Maybe we just need to emphasize to never destructure attrs. It's a simple rule to remember, even if its stroke is broader than strictly necessary. function Counter(ctx) {
const $count = m.stream(ctx.attrs.initial ?? 0)
const $changeCount = m.stream(-1)
$count.map(() => $changeCount($changeCount() + 1))
return () => [
m(".display", $count()),
m("button", {onclick() { $count($count() + 1) }}, $count())
]
}
I was thinking the same thing. Let's give some competition to react native :) I don't want to derail the conversation too much, but would a |
Beta Was this translation helpful? Give feedback.
-
Edit: @gilbert (forgot to tag you)
Yeah, I really don't like it (having a context object plus a closure), but it seems to be the best way to go. Maybe the following types are best: function Comp(ctx) {
return (prevAttrs?) => vnode
}
function Comp(ctx, prevAttrs?) {
return vnode
} (Of course, There would of course be no public
😎
If the renderer in question supports it, then sure. Not that I'd bake it into our size-optimized core renderer, though - I'd leave that mess to someone else to implement. But either way, it'd necessarily need to be on the context if you want to retain portability (and it'd give us a chance to banish global multi-root redraws while still allowing multiple roots - I really don't like our current system as it redraws way too many things). |
Beta Was this translation helpful? Give feedback.
-
Chewing on the latest a bit, I don't really understand the value proposition of
👍 Feels like a well-defined enough idea that it can be worked on separately. Wonder if this by itself would roughly be enough for using one of the XHR-aping node packages to work... Wouldn't be opposed to kicking the tires on this myself.
👍
Fair enough. Not sure I have a grasp of what the reserved words of the Which might become something like this(?) 🤔 function Comp(ctx, prevAttrs?) {
return m('h1.arbitrary',
m.access((dom, init) => ctx.mithril.request('/offplanet', ...),
'actualheadingtext')
} |
Beta Was this translation helpful? Give feedback.
-
Yeah, I'll stop including that But based on my suggestion with function Comp(ctx) {
m.request('/offplanet', {window: ctx.window, ...}).then(value => ctx.redraw())
return () => m('h1.arbitrary', 'actualheadingtext')
} Part of what drew me into |
Beta Was this translation helpful? Give feedback.
-
@orbitbot Caught this in Gitter: const Comp = () => {
const poll = (value, { mithril }) => value === '123' && mithril.request('offplanet', ...)
return (ctx) => m('input', m.access(({ value }) => poll(value, ctx)), 'somestring')
} Makes me wonder if it'd be better to structure the API like this: function Comp(ctx) {
return ctx => vnode
}
function Comp(ctx) {
return vnode
} That'd simplify dispatch a little while still providing all the same benefits. And as I do have a strong bias towards components as functions as functions naturally have names and this aids tremendously in debuggability. (I've already in the wild have had issues debugging object components, and have started to use closure components even for stateless components just to recover some of that, despite it being more boilerplate.) And if someone in the future writes dev tools integration, this would make their life easier, too. |
Beta Was this translation helpful? Give feedback.
-
Providing ctx to both is a great solution IMO. And to add and comment on the conversation, @barneycarroll writes:
I agree with this. For the record, I've had to use |
Beta Was this translation helpful? Give feedback.
-
Just FYI A problem I ran into with this style is that in this case I tend to use object destruction and use different properties for |
Beta Was this translation helpful? Give feedback.
-
As I noted before, they're the same reference, so there really isn't a semantic difference to beware of. Of course, ESLint may warn, and in that case, the ideal fix is to just pick a spot, one or the other. |
Beta Was this translation helpful? Give feedback.
-
Making noise on this one for discussion of v3 #2754. |
Beta Was this translation helpful? Give feedback.
-
Mithril version:
Platform and OS:
Project:
Is this something you're interested in implementing yourself?
Description
Replace these idioms:
With these:
As the rest of
vnode.*
properties are subsumed with other vnodes, I'd just provide the attrs themselves instead of full vnodes. (Vnode children would be moved toattrs.children
to align with virtually every other framework out there - I don't see the benefit in us remaining special here.)Why
Possible Implementation
createNode
, change state initialization to just this:updateNode
, invoke(0, vnode.state)(vnode.attrs, old.attrs)
instead ofvnode.state.view(vnode, old)
.Open Questions
onInit
fromm.access(...)
in Separate lifecycle methods from attributes and components #2689? I say no, because that would make it so users could still have that functionality outside components.Beta Was this translation helpful? Give feedback.
All reactions