DEV Community

Zacharias Enochsson
Zacharias Enochsson

Posted on

Working with DOM elements of view components in Hyperapp

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

... and this line

return (newVNode.node = node)
Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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())
  }
)
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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}, [
  ...
Enter fullscreen mode Exit fullscreen mode

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()
    })
  }
)
Enter fullscreen mode Exit fullscreen mode

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 around input.focus() now because when we dispatch the OnButtonFocus it causes the elements to be moved in the DOM. That causes them to lose focus so we wait to focus() until after they have moved.

Top comments (0)