Working with DOM elements of view components in Hyperapp
Zacharias Enochsson
Posted on April 20, 2022
Hyperapp's snappy and declarative virtual DOM engine is an excellent way to control your DOM most of the time. Yet there are the occasional moments when you need to work directly with the real DOM. No problem: you just use native browser apis to find and manipulate elements as needed – wrapped up in effects or subscriptions.
But sometimes I find myself wishing I could access a view-component's element within the component-definition itself. That way, I could encapsulate a component's behavior without having to burden my already creaky apps with more complexity in state, actions, effects and subscriptions. I find myself wishing there was some kind of Hyperapp-equivalent to React's useRef
-hook
As it turns out, it's possible using an unsupported implementation detail of Hyperapp (in other words: it's a hack). Here's how:
Magical properties
First, let me remind you of Object.defineProperty
. Among many other uses, it allows you to define a property on an object, as a pair of get
& set
functions. This means you can define a property that runs some code when it is accessed or assigned:
const obj = {}
let normalVal
Object.defineProperty(obj, 'normal', {
get () { return normalVal },
set (val) { normalVal = val }
})
Object.defineProperty(obj, 'wacky', {
get () { return -99 },
set (val) { console.log(val + ' ... really?!') }
})
obj.normal = 42
obj.normal // is 42
obj.wacky = 42 // logs "42 ... really?!"
obj.wacky // is always -99 regardless
You can't hide your secrets from me, Hyperapp!
Now have a look at Hyperapp's source code. Specifically this line
return (vdom.node = node)
... and this line
return (newVNode.node = node)
At two places in the patching process, hyperapp mutates your vnodes by assigning a node
property to them. Conveniently for us, the thing that gets assigned is the DOM element that corresponds to the vnode.
A VNode Decorator
This means we can make a decorator for vnodes like this:
const withElement = (vnode, fn) => {
let element
Object.defineProperty(vnode, 'node', {
get () { return element },
set (e) {
element = e
fn(element)
}
})
return vnode
}
And it will allow us to solve the example they use in the React docs for useRef
– a component with an input and a button. When the button gets clicked the associated input gets focused.
const TextInputWithFocusButton = () => withElement(
h('span', {}, [
h('input', {type: 'text'}),
h('button', {}, text('Focus input')),
]),
span => {
let [input, button] = span.childNodes
button.addEventListener('click', () => input.focus())
}
)
The function where we work with the real DOM elements (I'm going to call it the "element processor") will be called with its own corresponding span-element as input, for each instance where the component is used in the view.
Don't waste cycles
It's not perfect though, because the the element processor will be called every time the view is patched. Actually twice the first time the element is created. After we've hooked up the button click to input focus we don't need to do it again for that span. So we add a guard to make sure it only happens once.
span => {
// make sure we only do this once in the lifetime of
// each component like this:
if (span._seen) return
span._seen = true
let [input, button] = span.childNodes
button.addEventListener('click', () => input.focus())
}
Of key importance:
For this component to work correctly, it is crucial that Hyperapp continues to associates the same element with it. That is not guaranteed unless we add a key
property to the components root virtual node.
const TextInputWithFocusButton = (key) => withElem(
h('span', {key}, [
...
Try the full example live here!
Dispatch actions from Real-DOM-land?
You might still be wondering: how can we get back from DOM-land to hyper-land? How can we dispatch an action from our element-processing function?
First: it's very rare you would ever need to do that. (Technically you don't need any of this, but especially not that)
Second: Be careful you don't cause an infinite loop. Dispatching actions can change the state which causes the view to get patched, which will cause your element-processor function to get called again, again dispatching an action and so on.
Those caveats out of the way, here's what you do: You simply bind the action to a made up event in the a virtual node. In the element processor you dispatch the made up event. Piece of cake :)
const TextInputWithFocusButton = ({id, OnButtonFocus}) => withElement(
h('span', {key: id, onbuttonfocus: OnButtonFocus}, [
h('input', {type: 'text'}),
h('button', {}, text('Focus input')),
]),
span => {
if (span._seen) return
span._seen = true
let [input, button] = span.childNodes
button.addEventListener('click', () => {
span.dispatchEvent(new Event('buttonfocus'))
input.focus()
})
}
)
Here's the live example again, this time with an action that moves button-focused inputs to the top. (Verify that by entering different text in the different inputs)
Note: We need to add a
requestAnimationFrame
aroundinput.focus()
now because when we dispatch theOnButtonFocus
it causes the elements to be moved in the DOM. That causes them to lose focus so we wait tofocus()
until after they have moved.
Posted on April 20, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.