Skip to content

Simplify our component model #2295

@dead-claudia

Description

@dead-claudia
Initial proposal Based on #2293 and recent Gitter discussion, I think we could get away with simplifying our component model some in v3. **Edit:** My proposal has since expanded and got a little broader - https://github.com//issues/2295#issuecomment-439846375.
  • Object components would become stateless - their vnode.state would just be set to vnode.tag.
  • Closure components would be kept as-is.
  • Class components would disappear in favor of closure components.
  • vnode.oninit would disappear in favor of closure component constructors.
  • I'd rename vnode.state to vnode._hooks so it's clearer it's meant to be internal.

The reason for this:

  • Object components would then have zero memory overhead, so we could recommend them for memory optimization.
  • Closure components are more concise than class components, while offering more or less the same benefits of having an initialization step.
  • It makes no sense having an oninit with a constructor step, and with the only reason they exist (stateful object components) removed, it's kinda useless.
Here's most of the implementation changes.

This simplifies this function to this:

function initComponent(vnode, hooks) {
    var sentinel = vnode.tag
    if (typeof sentinel === "function") {
        if (sentinel.$$reentrantLock$$ != null) return
        sentinel.$$reentrantLock$$ = true
        vnode.state = sentinel(vnode)
    } else {
        vnode.state = sentinel
    }
    if (vnode.attrs != null) initLifecycle(vnode.attrs, vnode, hooks)
    initLifecycle(vnode.state, vnode, hooks)
    vnode.instance = Vnode.normalize(callHook.call(vnode.state.view, vnode))
    if (vnode.instance === vnode) throw Error("A view cannot return the vnode it received as argument")
    if (typeof sentinel === "function") sentinel.$$reentrantLock$$ = null
}

BTW, this route is way more flexible than React's function components (pre-hooks):

  • React just lets you specify the view.
  • This lets you specify hooks and mutate the DOM as well.

Updates

This is part 2 of a broader proposal: #2278.

Let's simplify our component model for v3.

So far, every single one of our component types suck in some way:

  • Object components have vnode.state = Object.create(null), causing user confusion on a frequent basis when they mistakenly do new Comp(...) because this === Object.create(new Comp(...)), not this === new Comp(...). They also get really verbose because in order to avoid this issues, you're stuck with vnode.state.foo and similar, and destructuring isn't a real alternative when mutations get involved.
  • Class components have major this issues. React users extensively use bound class methods (method = (...args) => this.doSomething(...args)) to work around this, and it's no different with us.
  • Closure components get very nesting-happy, and it's very easy to forget to shadow the first vnode argument.

Also, every one of our lifecycle methods have issues:

  • oninit is 100% duplicated by class constructors and closure factories.
  • onbeforeupdate could literally be solved by a "don't redraw" sentinel + passing both parameters to the view. This also has other benefits: Extend (present, previous) signature to all component methods  #2098.
  • onbeforeremove and onremove are basically one and the same. You could get equivalent behavior by just calling the parent onremove, awaiting it, then calling the child onremoves depth-first without awaiting them.
  • oncreate and onupdate are unnecessary boilerplate - we could just as easily switch to a m.update that just schedules callbacks on the next tick.1, and you'd call this in the same place you'd render.

And finally, our component model is reeking of broken, leaky abstractions.

  • We have a ton of auto-redraw magic (ranging from m.request to DOM event handlers), yet we still frequently m.redraw.
  • For things like async loading, it's a waste to have onremove after it's loaded.
  • Several of us can remember the big deal over vnode.state being made no longer assignable in v2 for performance reasons.
  • I find myself needing the previous vnode just as often in onupdate as I do in onbeforeupdate.

Proposal

So here's my proposal: let's model components after reducers. Components would be single simple functions with four simple parameters:

  • The current vnode.
  • The previous vnode, or undefined if it's the first render.
  • An update function that accepts new state and schedules a global redraw.

Components would return either a vnode tree or an object with three properties:

  • view (required): This is the vnode tree you plan to render.
  • next (required): This is the vnode.state to use in the next component call.
  • onremove (optional): This is called when a component is removed from the DOM.

The hook onremove would be called on the parent node first, awaited from there, and then on resolution, recursively called on child elements depth-first without awaiting.

The vnode.state property would become the current state, and old.state would represent the previous state. vnode.update is how you update the current state, and it replaces the functionality of event.redraw and m.request's background: false. When called, it sets the next state synchronously and invokes m.redraw(). update itself is a constant reference stored for the lifetime of the component instance, for performance and ease of use - most uses of it need that behavior.

This would, of course, be copied over between vnode and old.

Here's how that'd look in practice:

function CounterButton({attrs, state: count = 0, update}, old) {
	if (old && attrs.color === old.attrs.color && count === old.state) return old
	return {
		next: count,
		m("button", {
			style: {color: attrs.color},
			onclick: () => update(count + 1)
		}, "Count: ", count)
	}
}

// Or, if you'd prefer arrow functions
const CounterButton = ({attrs, state: count = 0, update}, old) =>
	old && attrs.color === old.attrs.state && count === old.state ? old : {
		next: count,
		m("button", {
			style: {color: attrs.color},
			onclick: () => update(count + 1)
		}, "Count: ", count)
	}

For comparison, here's what you'd write today:

function CounterButton() {
    let count = 0
    let updating = false

    return {
        onbeforeupdate(vnode, old) {
            if (updating) { updating = false; return true }
            return vnode.attrs.color !== old.attrs.color
        },

        view(vnode) {
            return m("button", {
                style: {color: vnode.attrs.color},
                onclick: () => { updating = true; count++ }
            }, "Count: ", count)
        },
    }
}

Attributes

If I'm going to ditch lifecycle attributes, I'll need to come up with similar for elements. This will be a single magic attribute onupdate like the above, with the same vnode and old, but returned vnode trees and returned state are ignored - you can only diff attributes. In addition, the third update argument does not exist. There's reasons for ignoring each:

  • Vnode children should be specified in the children, not the hook.
  • It's unclear whether the state should be for the attributes only or if it should receive the vnode state. And even in that case, it seems wrong for you to be able to replace it because it'd break encapsulation.

You can still return old or {view: old, ...} to signify "don't update this vnode". That's the only reason view is ever read.

Mapping

The hooks would be fused into this:2

  • oninit would become just the first component call. The component would be rendered as tag(vnode, undefined, undefined, update), and the first render can be detected by the previous vnode being undefined.
  • oncreate would become invoking m.update in the first component call.
  • onbeforeupdate would be conditionally returning old from the component.
  • view would be returning the vnode tree from either the top level or via the view property.
  • onupdate would be invoking m.update on subsequent component calls.
  • onbeforeremove would be optionally returning a promise from onremove.
  • onremove would be returning a non-thenable from onremove.

Helpers

There are two new helpers added: m.update and m.changed. m.update is literally just sugar for scheduling a callback to run on the next tick, but this is practically required for DOM manipulation. m.bind dramatically simplifies things like subscription management, by implicitly cleaning things up for you as necessary.

m.changed is a simple helper function you call as const changed = m.changed(state, key, prevKey), and is a cue for you to (re-)initialize state. For convenience, m.changed(state, vnode, old, "attr") is sugar for m.changed(state, vnode.attrs[attr], old && old.attrs[attr]), since the common use here deals with attributes.

  • When this returns false, you should continue using state as necessary.
  • When this returns true, it invokes state.close() if such a method exists and signals to you that you should reinitialize it.
  • For convenience, when state == null, this returns true.

Why this?

I know this is very different from the Mithril you knew previously, but I have several reasons for this:

Hooks vs reducer

When you start looking into how the hooks work conceptually, it stops being as obvious why they're separate things.

  • oncreate/onupdate are always scheduled after every call to view, and the code to explicitly schedule them is sometimes simpler than defining the hooks manually. In this case, you might as well put them in the view directly - you'll save Mithril quite a bit of work.
  • The first render with v1/v2 is always oninit > view > schedule oncreate. If you couple the current state to the current view, you can merge this entire path.
  • Subsequent renders with v1/v2 are always onbeforeupdate > view > schedule onupdate. If you provide the previous attributes, allow returning a "don't update the view" sentinel, and couple the current state to the current view, you can merge this entire path.
  • Usually, you're doing mostly the same thing regardless of if it's the first render or a subsequent one. And when you're not, the differences are usually pretty small.
  • By always providing the old and new vnodes, it's much easier to react to differences without having to keep as much state.

And so because they are so similar, I found it was much simpler to just reduce it to a single "reducer" function. For the first render, it's pretty easy to check for a missing previous vnode (old == null).

If you'd like to see how this helps simplify components:

This non-trivial, somewhat real-world example results in about 25% less code. (It's a component wrapper for Bootstrap v4's modals.)

// v1/v2: 44 lines
function Modal(vnode) {
    let showing = !!vnode.attrs.show

    return {
        oncreate: vnode => {
            $(vnode.dom).modal(vnode.attrs.options)
            if (showing) $(vnode.dom).modal("show")
            else $(vnode.dom).modal("hide")
        },

        onupdate: vnode => {
            const next = !!vnode.attrs.show
            if (showing !== next) {
                showing = next
                $(vnode.dom).modal("toggle")
            }
        },

        onremove: vnode => {
            $(vnode.dom).modal("dispose")
        },

        view: vnode => m(".modal[tabindex=-1][role=dialog]", vnode.attrs.modalAttrs, [
            m(".modal-dialog[role=document]", {
                class: vnode.attrs.centered ? "modal-dialog-centered" : "",
            }, [
                m(".modal-content", [
                    m(".modal-header", vnode.attrs.headerAttrs, [
                        m(".modal-title", vnode.attrs.titleAttrs, vnode.attrs.title),
                        vnode.attrs.header,
                    ]),
                    m(".modal-body", vnode.attrs.bodyAttrs, vnode.attrs.body),
                    m(".modal-footer", vnode.attrs.footerAttrs, vnode.attrs.footer)
                ])
            ])
        ])
    }
}

Modal.Dismiss = {view: ({attrs, children}) => {
    let tag = attrs.tag || "button[type=button].close"
    tag += "[data-dismiss=modal][aria-label=Close]"
    return m(tag, attrs.attrs, children)
}}

// This proposal: 32 lines
function Modal(vnode, old) {
    if (!old || !!vnode.attrs.show !== !!old.attrs.show) {
        m.update(() => {
			if (!old) $(vnode.dom).modal(vnode.attrs.options)
			$(vnode.dom).modal(vnode.attrs.show ? "hide" : "show")
		})
    }

    return {
		onremove: () => $(vnode.dom).modal("dispose"),
        view: m(".modal[tabindex=-1][role=dialog]", vnode.attrs.modalAttrs, [
	        m(".modal-dialog[role=document]", {
	            class: vnode.attrs.centered ? "modal-dialog-centered" : ""
	        }, [
	            m(".modal-content", [
	                m(".modal-header", vnode.attrs.headerAttrs, [
	                    m(".modal-title", vnode.attrs.titleAttrs, vnode.attrs.title),
	                    vnode.attrs.header
	                ]),
	                m(".modal-body", vnode.attrs.bodyAttrs, vnode.attrs.body),
	                m(".modal-footer", vnode.attrs.footerAttrs, vnode.attrs.footer)
	            ])
	        ])
	    ])
	}
}

Modal.Dismiss = ({attrs, children}) => {
    let tag = attrs.tag || "button[type=button].close"
    tag += "[data-dismiss=modal][aria-label=Close]"
    return m(tag, attrs.attrs, children)
}

State

I chose to make state in this iteration with a heavy preference for immutability because of two big reasons:

  1. It's much easier to use and reason about with immutable state.
  2. You can diff the state in a central place, if you treat it immutably. This is one of the few useful things React has.

I also coupled it to the view so it's easier to diff and update your state based on new attributes without having to mutate anything.

It's not completely anti-mutation, and it's not nearly as magical as React's setState - it's more like their replaceState. You can still do mutable things in it and it will work - you could take a look at the Media component definition here3. It just makes it clear that in smaller scenarios, there may be easier ways to do it, and it encourages you to do things immutably with smaller state. For large, stateful components, it's still often easier to just mutate state directly and do vnode.update(state) - you get the benefits of partial updates without the cost of constantly recreating state.

No auto-redraw anymore?

Generally not. But because update both replaces the state and auto-redraws,

Motivation

My goal here is to simplify Mithril's component model and help bring it back to that simplicity it once had. Mithril's components used to only consist of two properties in v0.2: a controller constructor and a view(ctrl, ...params): vnode method. You'd use this via m(Component, ...params), and it would be fairly consistent and easy to use.4

It also had far fewer lifecycle hooks - it only had two: attrs.config(elem, isInit, ctx, vnode) for most everything that requires DOM integration and ctrl.onunload(e)/ctx.onunload(e) for what we have onremove for. If you wanted to do transitions, you'd do m.startComputation() before you trigger it and m.endComputation() after it finishes, and this would even work in onunload. If you wanted to block subtree rendering, you'd pass {subtree: "retain"} as a child.5

I miss that simplicity in easy-to-understand component code, and I want it back. I want all the niceness of v0.2's components without the disappointment.

Notes

  1. m.update would literally be this. It's pretty easy, and it's incredibly easy to teach. It's also simpler to implement than even m.prop or m.withAttr.

    var promise = Promise.resolve()
    m.update = function (callback) { promise.then(callback) }
  2. For a more concrete explanation of how hooks correspond to this, here's how I could wrap legacy v1/v2 components for migration:

    function migrateComponent(tag) {
    	const makeSynthetic = ({
    		key, attrs, children, text, dom, domSize
    	}, state) => ({
    		tag, key, attrs, children, text, dom, domSize, state,
    	})
    
    	return (vnode, old) => {
    		let current, test
    		if (old == null) {
    			current = makeSynthetic(vnode, undefined)
    			if (typeof tag !== "function") {
    				current.state = Object.create(tag)
    			} else if (tag.prototype && typeof tag.prototype.view === "function") {
    				current.state = new tag(current)
    			} else {
    				current.state = tag(current)
    			}
    			current.state.oninit(current)
    			m.update(() => current.state.oncreate(current))
    		} else {
    			const prev = vnode.state
    			current = makeSynthetic(vnode, vnode.state.state)
    			test = current.state.onbeforeupdate(vnode.state, current)
    			if (test == null || test) m.update(() => current.state.onupdate(current))
    		}
    
    		return {
    			next: current,
    			view: test == null || test ? current.state.view(current) : old,
    			onremove() {
    				const result = current.state.onbeforeremove(current)
    				if (result && result.then === "function") {
    					return Promise.resolve(result).then(() => {
    						current.state.onremove(current)
    					})
    				} else {
    					current.state.onremove(current)
    				}
    			}
    		}
    	}
    }
  3. Here's the source code for the Media component, used in an example in Make the router use components, but only accept view functions #2281. It's basically a stripped-down port of react-media.

    // Okay, and this is why I like Mithril: it's not super complicated and full of
    // ceremony to do crap like this.
    function Media(vnode, old) {
    	let queryList = vnode.state
    
    	if (m.changed(queryList, vnode, prev, "query")) {
    		let mediaQueryList = window.matchMedia(query)
    		// Safari doesn't clean up the listener with `removeListener` when the
    		// listener is already waiting in the event queue. The `mediaQueryList`
    		// test is to make sure the listener is not called after we call
    		// `removeListener`.
    		queryList = {
    			matches: () => mediaQueryList.matches,
    			cleanup: () => {
    				mediaQueryList.removeListener(updateMatches)
    				mediaQueryList = undefined
    			}
    		}
    		function updateMatches() { if (mediaQueryList) m.redraw() }
    		mediaQueryList.addListener(updateMatches)
    	}
    
    	return {
    		next: queryList,
    		view: vnode.attrs.view(queryList.matches()),
    		onremove: queryList.cleanup,
    	}
    }
  4. Well...not always easy. Mithril v0.2 didn't normalize children to a single array, so proxying children got rather inconvenient at times. What this means is that if you used m(Comp, {...opts}, "foo", "bar"), the component's view would literally be called as Comp.view(ctrl, {...opts}, "foo", "bar"), so you'd often need to normalize children yourself. It also didn't help that this was all pre-ES6, or it would've been a little more tolerable.

  5. Most of the internal complexity and bugs in v0.2 were caused indirectly by one of two things: the spaghetti mess of a diff algorithm and m.redraw.strategy() causing hard-to-debug internal state issues thanks to constantly ruined assumptions. Oh, and insult to injury: e.preventDefault() in ctrl.onunload or ctx.onunload means you can't even assume unmounting the tree clears it or that route changes result in a fresh new tree anywhere. It's all these kinds of papercuts caused by internal bugs in a seriously messy code base that led Leo to rewrite and recast the entire API for v1.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type: Breaking ChangeFor any feature request or suggestion that could reasonably break existing code

    Type

    No type

    Projects

    Status

    Completed/Declined

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions