DEV Community

Dwayne Crooks
Dwayne Crooks

Posted on

Smart Event Listeners

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

A screenshot of Drum Machine

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

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

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

A screenshot of Elm 2048

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

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:

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 (2)

Collapse
 
dwayne profile image
Dwayne Crooks • Edited

Update: I've found the following function to be useful when implementing smart event listeners.

fromMaybe : Maybe a -> JD.Decoder a
fromMaybe ma =
    case ma of
        Just a ->
            JD.succeed a

        Nothing ->
            JD.fail "ignored"
Enter fullscreen mode Exit fullscreen mode

Then, onVolumeInput becomes

onVolumeInput : (Volume -> msg) -> H.Attribute msg
onVolumeInput onVolume =
    HE.targetValue
        |> JD.andThen (Volume.fromString >> Maybe.map onVolume >> JD.fromMaybe)
        |> HE.on "input"
Enter fullscreen mode Exit fullscreen mode

See this commit for a full example.

Collapse
 
tbm206 profile image
Taha Ben Masaud

Nice article