Working with DOM elements of view components in Hyperapp
Tags: hyperapp, dom, javascript, frontend
Published: 2022-04-20 23:59:50

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](https://reactjs.org/docs/hooks-reference.html#useref)

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:

```js

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](https://github.com/jorgebucaran/hyperapp/blob/876d3f44d0ae601de181714c583a07d83d3472c9/index.js#L121) 

```js
return (vdom.node = node)
```

... and [this line](https://github.com/jorgebucaran/hyperapp/blob/876d3f44d0ae601de181714c583a07d83d3472c9/index.js#L316)

```js
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:

```js

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.
   
 ```js
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.

```js
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](https://github.com/jorgebucaran/hyperapp/blob/main/docs/architecture/views.md#keys) to the components root virtual node. 


```js
const TextInputWithFocusButton = (key) => withElem(
  h('span', {key}, [
  ...
```

[Try the full example live here][example]!

## 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 :) 

```js
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][example2], 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.

[example]: https://flems.io/#0=N4IgZglgNgpgziAXAbVAOwIYFsZJAOgAsAXLKEAGhAGMB7NYmBvEAXwvW10QICsEqdBk2J4IWAA60ATsQAEwQhTmMAHsWUYJE1nLDTaWOQHJCATwkxpWicYA6aIXHkB3CMUIBRWDgZyAvHIAFABuaLQAJjDKYGgAlAEAfAoOcnKw8jA+IqlyAPIARrww1MT4UZBoMAAKBpayZqHhUcrGzTDGysC5aQDmMPJBCcBy0gMArtJoclkwvvLsPXJwA8Eww0tps-MBM5t6aEHbInFLrLmsp9OjE1NyYZEwDudoDk7yACow6gCSaBLjYgAdXchAAYrRqOM4AAhQHEei7IIAaxgZgS-mSbg83jmIiCuUIQWMcAkGDQnQUqLM7DkyCWROMEH+gMpwGIFhgiBMamIxkuFAZxIK8PobNpvOJEKhcDkzIBfLicUF1wAusrcqTyUkUtc0hAwMEtWh8AB9FZMBJjYiTV565Zkk3mmBMXbEaTjJ72jJ0+WA5Qi4gItCq3bG-DUQjQCIAOUecCWgeD+AwEQinhCIgAMhBnEwrMTqFAINRkZShjq-WUwJDoUMrmkXlcHDYgt1riEIDAXNyK5i5IyCpEzOLlPT7YysBhmaO6fsvr8WcDQdLoXCg-RiRhjBr7WlB9Jxbu0ie5AviH8FSCPKvYaLDsYCjuVaf98LD10BfPvhel9fwbWd4bg+1DPks6q5OqL5yO0iARLW8z4EOERmM8cSUCAKywKUED0AgPAAIwACyIARABsbAcCAmA4HgEZwAIND0IwzA8GwqqsEAA

[example2]: https://flems.io/#0=N4IgZglgNgpgziAXAbVAOwIYFsZJAOgAsAXLKEAGhAGMB7NYmBvEAXwvW10QICsEqdBk2J4IWAA60ATsQAEwQhTmMAHsWUYJE1nLDTaWOQHJCATwkxpWicYA6aIXHkB3CMUIBRWDgZyAvHIAFABuaLQAJjDKYGgAlAEAfAoOcnKw8jA+IqlyAPIARrww1MT4UZBoMAAKBpayZqHhUcrGzTDGysC5aQDmMPJBCcBy0gMArtJoclkwvvLsPXJwA8Eww0tps-MBM5t6aEHbInFLrLmsp9OjE1NyYZEwDudoDk7yACow6gCSaBLjYgAdXchAAYrRqOM4AAhQHEei7ILACARZR5NBw4gItAQqFwS5JORuDzeOYiIK5QhBYxwCQYNCdBQAaxgZkQclRynoBXh9DAkOhHIxWJxeOh7DkyCW1OMEH+gKZwGIFhgHOMamIxkuFBlNN52PoSslmpp4rgnIVWricV11wAurbcnSGUTutc0hAwMEXWh8AB9FZMBJjYiTV4e5b0v2BmBMXbEaTjJ6RjJS+UAjRyA04+27X34aiEaARAByjzgSxz9HwGAiEU8IREABkIM4mFYadQoBBqMymUM3fsCxE2-TiEXGxSqi45FOGPq+WgBfjjDb9mMAI7J5wAQTQ4gwxAg9DB1hwQUH-mSGcB+BX0KGVzSaUuFwcVze9GcclFp8FFqBEEzhHtEnIRAk17BO6aT4HBIGMHanpWnAHLIFychwfgCEwPgt7EHA97QIw0hBKoRLkQAhP4gSonE9rPJ+mDaMiuTyu4HIoihaHGBgTLGAU-HUMY9qLNcIQQDALgcjhRKygUkRmMayjSpGspYBg8rKcsxCgXhKH4BpEhBKiRKqS+L5fL8Vogh45p-ocMEWS+XL7GkIpLuaaEOeayiogxkavk6gVyPJ0jGsFFmOveUBHk+uSOkhcjtIgESCvM+AKREZiMZQIArLApQnmgCA8AATAALIgFUAOxsBwICYDgeCFnAAg0PQjDMDwbCiUAA