Elm & the DOM

Please note: The source code in this post no longer compiles as Elm has been updated to 0.18. Refer to the github page for functioning examples.

In this post, I’ll look at what you can do if you are writing an Elm app and need to figure out the dimensions the browser chose for your DOM elements. We’ll see how to read properties like .offsetHeight off the DOM and find a substitute for getBoundingClientRect.

In Javascript, if I want to know the height of a rendered element, I can do this:

var height = 
document.getElementById(“my-important-element”).offsetHeight;

But the Elm standard libraries doesn’t have an offsetHeight function. Why not?

Elm has two major hurdles to overcome when working with the DOM:

  1. Elm functions are stateless, but the DOM mutates. A function that reads “offsetHeight” of a DOM element cannot be stateless. It would return some value, then after the user resizing the browser window, it would return a different value.
  2. If you use VirtualDom, you do not have direct access to the DOM anyway.

Parsing Javascript events

There is one place in Elm where you can overcome both these problems, though: In event-handlers. While processing event-handlers, the DOM does not mutate (fixing 1), and DOM events contain references to DOM nodes (fixing 2).

In the “Html.Events” library, we find this example:

Note the implementation: the function on takes a Json.Decode.Decoder msg and returns an (event-handler) Attribute msg. The decoder will be applied to the Javascript Event structure passed to the event-handler. (If you’re interested in the details, here is the source for on; it’ll result in this function being called during virtual-dom rendering.)

The Javascript Event contains a target property which is a reference to the actual DOM element where the event originated. The targetValue decoder in the above example simply extracts the value from that property:

To read offsetHeight, we can do the same trick:

So this is how we read properties off the DOM: We supply a JSON decoder to on which retrieves properties of the target DOM node.

Walking the DOM

But what if the element that triggered the event is not the one whose properties we want to read?

DOM elements has properties for their parent and children, so we can write an encoder which walks the DOM. Since decoders fail silently, writing such walks is pretty harrowing; I packed a few useful decoders up in a library to avoid repeating silly mistakes. Using that library, you can do examples like this one, which finds the grand-children of a sibling to the button element originating the event:

That is, we can “walk the DOM”, starting from the target DOM node provided to us by on,

Beyond properties

Unfortunately, not everything we might want to know about a DOM element is exposed as properties. In particular getBoundingClientRect, the canonical way to get the absolute position of a DOM element, is a method on Element, not a property, so we cannot write a Json.Decoder for that.

We could use ports, but for library writers, that’s unacceptable. Currently (Elm 0.17), ports require users to put various boilerplate in their top-level app, as well as custom Javascript, drastically increasing your library’s barrier to entry.)

There was a time when getBoundingClientRect wasn’t supported by all browsers. People would do stuff like this instead:

That‘s actually not so bad: We walk up the DOM, collecting geometry as we go. We just saw that we can walk the DOM from Elm, so let’s re-implement the above function as a JSON decoder. We’ll have to rephrase the while loop as a recursive function instead, but that just makes the implementation clearer.

First, we decode scrollLeft, -Top, offsetLeft, and -Top on the current element and add that suitably to the given x and y; andThen we pass those new x and y to the same decoder, only run at the parent node. The offsetParent decoder returns its first argument if you’re already at the root.

What you cannot do

The thing that’s missing from this approach is the ability to read off properties of DOM elements without having to wait for a DOM event.

Except for ports, I don’t think Elm gives us a way to do this.

What you shouldn’t do

“But wait!”, you say. “If target is a reference to a DOM node, can’t you just store that reference as reference : Json.Value in your model? Then you could run decoders on that whenever you liked—no need to wait for a DOM event!”

Well, yes. At least after the first time I get a DOM event.

But, no. I’d be violating the core principle of Elm that values are immutable. If I write a function measure : Json.Value -> Float that takes that reference and returns, say, the width of some element, that function could return different results for the same input. You call measure model.reference, get 50, you resize the browser, call it again with the same argument measure model.reference, and now you get 60. It’s a central point of Elm (and most functional languages) that functions are stateless, i.e., on the same input, they give the same result. Always.

Conclusion

So, with a bit of hackery, you can read properties off the DOM. You don’t get to call methods—in particular getBoundingClientRect—and you need to wait for a DOM event to happen first, though. You can get a good bit of the way even so; in particular, it is enough for the common case of starting geometry-dependent animations in response to a click.

The present post suggests two connected points where Elm could improve:

  1. Its inconvenient and a bit surprising that Elm doesn’t have more direct support for reading properties off the rendered DOM, especially geometry. As a language for the web-platform, Elm should give me a convenient way to get the information provided by the offsetWitdth property and the getBoundingClientRect method.
  2. It is unnerving that I can abuse the event-parsing mechanism of Html.Events.on to introduce stateful functions. Using that, I can in principle undermine all the neat guarantees that the language otherwise gives me. That shouldn’t be possible.

Neither point seems particularly difficult to fix, although there is of course some tension between (1) introducing an API for accessing DOM nodes on the one hand, and (2) making sure the values of that API doesn’t mutate on the other.

Show your support

Clapping shows how much you appreciated Søren Debois’s story.