In Elm, event listeners establish a boundary between your application and the outside world. As a result they provide the perfect opportunity to apply the ideas of "Parse, don't validate". I call event listeners that apply these ideas, smart event listeners, because they serve a similar purpose as smart constructors. In fact, smart event listeners usually make use of smart constructors in their implementation.
What are event listeners?
All the functions (except for the decoders) in Html.Events
are called event listeners. For e.g. onClick
, onInput
, and onFocus
are event listeners. As you're probably aware, you aren't limited to predefined event listeners because you can define custom event listeners using on
and a few other functions.
An event listener listens for a specific event and it may or may not grab information from the event using a decoder. It then produces a message that gets routed to your update
function where you can subsequently handle the message.
What are smart event listeners?
A smart event listener is an event listener that grabs unstructured information from an event and constructs a message that contains structured data pertinent to your application's needs.
Parse, don't validate
In the popular article, Parse, don't validate, Alexis King writes about the power of using data types to inform your code. The following points she makes are relevant to our discussion:
The difference between parsing and validating lies in how information is preserved.
A parser is just a function that consumes less-structured input and produces more-structured output.
Parsers are an incredibly powerful tool: they allow discharging checks on input up-front, right on the boundary between a program and the outside world, and once those checks have been performed, they never need to be checked again!
Elm's JSON decoders are parsers in this regard and as it turns out we can use JSON decoders to help us create smart event listeners.
Example 1: A volume control
Drum machine is an Elm app based on freeCodeCamp's Build a Drum Machine front-end project. One of the components present in the application is a volume control. The control allows you to adjust the volume of the machine from 0 all the way up to 100.
We can use a range input for the volume control.
view : (Volume -> msg) -> Bool -> Volume -> H.Html msg
view onVolume isDisabled volume =
H.input
[ HA.type_ "range"
, HA.min "0"
, HA.max "100"
, HA.step "1"
, HA.class "slider"
, HA.value <| Volume.toString volume
, if isDisabled then
HA.disabled True
else
onVolumeInput onVolume
]
[]
When the user changes the volume an input event is triggered and we can grab the new value of the volume off the event's target.value
attribute which is a String
(unstructured information). Rather than immediately wrap that String
in a message that gets routed to our update
function (which is what the onInput
event listener does), we can instead "parse" that String
into a value of type Volume
(structured information) that has more meaning to our application. The smart event listener onVolumeInput
takes that approach.
onVolumeInput : (Volume -> msg) -> H.Attribute msg
onVolumeInput onVolume =
let
decoder =
HE.targetValue
|> JD.andThen
(\s ->
case Volume.fromString s of
Just volume ->
JD.succeed <| onVolume volume
Nothing ->
JD.fail "ignored"
)
in
HE.on "input" decoder
Notice how the smart constructor, Volume.fromString
, is used in decoder
to "parse" the String
into a Volume
. On success, a value of type Volume
gets routed to our update
function. Furthermore, if the decoder fails because the target.value
represents an invalid volume then Elm silently ignores the event and we never have to worry about dealing with bad volumes in our update
function.
Feel free to learn more by checking out the original code.
Example 2: Keyboard movement
In my 2048 clone you can use the keyboard to move the tiles. A keydown event listener is attached to the main application container in order to detect your key presses and determine what message (if any) to route to your update
function.
onKeyDown : (Grid.Direction -> msg) -> msg -> H.Attribute msg
onKeyDown onMove onNewGame =
let
keyDecoder =
JD.field "key" JD.string
|> JD.andThen
(\key ->
case ( key, String.toUpper key ) of
-- Arrow keys: Up, Right, Down, Left
( "ArrowUp", _ ) ->
JD.succeed <| onMove Grid.Up
( "ArrowRight", _ ) ->
JD.succeed <| onMove Grid.Right
( "ArrowDown", _ ) ->
JD.succeed <| onMove Grid.Down
( "ArrowLeft", _ ) ->
JD.succeed <| onMove Grid.Left
-- Vim: KLJH
( _, "K" ) ->
JD.succeed <| onMove Grid.Up
( _, "L" ) ->
JD.succeed <| onMove Grid.Right
( _, "J" ) ->
JD.succeed <| onMove Grid.Down
( _, "H" ) ->
JD.succeed <| onMove Grid.Left
-- WDSA
( _, "W" ) ->
JD.succeed <| onMove Grid.Up
( _, "D" ) ->
JD.succeed <| onMove Grid.Right
( _, "S" ) ->
JD.succeed <| onMove Grid.Down
( _, "A" ) ->
JD.succeed <| onMove Grid.Left
-- Restart
( _, "R" ) ->
JD.succeed onNewGame
_ ->
JD.fail "ignored"
)
in
keyDecoder
|> JD.map (\msg -> ( msg, True ))
|> HE.preventDefaultOn "keydown"
As you can see the onKeyDown
event listener is another example of a smart event listener. It grabs the pressed key (unstructured information) and transforms it into an application specific data structure (structured information, Grid.Direction
in the case of the movement keys). Notice that if any message ever gets routed to our update
function we know with certainty that the user either pressed one of the movement keys or they tried to restart the game.
Feel free to learn more by checking out the original code.
More examples
Here are a few more examples:
- Selecting a one way or return flight in the Flight Booker task.
- See
viewFlight
andviewSelect
.
- See
- Changing the duration in the Timer task.
- See
onDurationInput
.
- See
- Changing the diameter of a circle in the Circle Drawer task.
- See
onDiameterInput
.
- See
-
elm-limiter allows you to do debouncing and throttling through custom
onClick
andonInput
event listeners.- See
Limiter.Events
.
- See
Conclusion
Smart constructors, JSON decoders, and "Parse, don't validate" all combine to give us a powerful tool, smart event listeners, to patrol the borders of our Elm web apps. As a result, we get to decide what data gets in and in what form we want to work with it.
P.S. Evan has a short note he wrote about this very idea in the context of handling movement input for a game. I read it before but I can't seem to find it anywhere. If any of you come across the note please share it with me. Thanks!
Top comments (3)
Update: I've found the following function to be useful when implementing smart event listeners.
Then,
onVolumeInput
becomesSee this commit for a full example.
I found the notes by Evan: github.com/elm/browser/blob/1.0.2/....
Nice article