Let's build a dad joke generator in Elm!
What does the Elm developer say when asked to go out to party? Maybe
. Oh yes. Welcome to the shit show that is my dad joke game 😎
After creating both a counter and a todo app in Elm, I'm ready to go for the big leagues. Create that real business value. Satisfy the client needs in a functional fashion. So I decided to create dad joke generator via the fantastic and free Dad Joke API!
Even though the feature might be laughable (hah! 😄), it's no joke to implement - at least not for me. We'll have to do HTTP calls, set the correct headers, and deal with something called commands.
In this article, I'm going to take you through what I did step by step, and try to explain the new concepts I encounter.
I like starting with modeling the state of our application. In this case, we have three possible states - waiting for data, having failed to fetch the data, and successfully fetching the data. We can model that with a regular type:
type Model
= Failure
| Loading
| Success String
I guess we could've created an Idle
state as well, but we're going to fetch a joke initially anyhow, so it won't be a need for that. Speaking of...
The next thing we want to do is intializing our application. It'll be a bit different than we've done previously - instead of just being the initial model, it'll now be a function that returns something called a tuple.
The type signature looks like this:
init : () -> (Model, Cmd Msg)
There's a few new things here. First, what's a tuple? In this case, you can think of it as way to return several values from a function. It's a bit more complex than that, but you can think of them as a very light weight data structure of sorts.
In our case though, we're just returning two things - our initial model, and an initial Cmd
. But what is a Cmd
?
A Cmd
(or command) is something you want Elm's runtime to do for you. It can be a lot of things, like creating random numbers or accessing browser APIs. Or doing HTTP calls. Once it's resolved somehow, it will return a Msg
, which our update
function will deal with.
If you're coming from a React background, you can think of thisCmd
argument to ourinit
function as an action you'd want to run on mount. Kind of likecomponentDidMount
oruseEffect(fn, [])
works!
So let's write our initializer!
init : () -> (Model, Cmd Msg)
init _ =
( Loading
, Http.get
{ url = "https://icanhazdadjoke.com"
, expect = Http.expectString GotJoke
}
)
We return a tuple with our initial model - Loading
, and a call to the Http.get
function.
To get this to compile, you need to install theelm/http
package withelm install elm/http
in your terminal, and then imported in your file withimport Http
.
The Http.get
function accepts a record with two parameters - the url
, and something called expect
. This last one uses another function - Http.expectString
, which tells Elm to parse the response as a string, and then "dispatch" the GotJoke
message with the result.
But - we haven't specified our messages yet - let's do that next.
For now, we only have one possible message to send in our app - namely the "we received a response from the server" value.
That "we received a response from the server" is wrapped in a Result
type, which can either be an error or the successfully parsed response. We can specify that like so:
type Msg =
GotJoke (Result Http.Error String)
Now, I'm no functional programming nut, but if I've understood the introductory blog posts correctly, Result
is what's known as a monad. 😱 Now, after the obligatory panic attacks, I realized monads a just containers for a value that adds some functions for you to use. However, if you're new to functional programming, just close your eyes to the monad part, and just think of Result
as a type that's either Ok
or Err
.
The update function is a bit different this time around as well. The type signature looks like this:
update : Msg -> Model -> (Model, Cmd Msg)
Like before, the update function receives the message and the initial model, but now it's returning a tuple - two values - (Model, Cmd Msg)
. This feature lets a message trigger a command, if we want to.
Let's implement it:
update : Msg -> Model -> ( Model, Cmd Msg )
update msg _ =
case msg of
GotJoke result ->
case result of
Ok joke ->
( Success joke, Cmd.none )
Err _ ->
( Failure, Cmd.none )
Here, we pattern match the message - even though we just have a single message. By doing it this way, we'll be able to add features without refactoring too much later.
Inside of the GotJoke
match, we're doing another pattern matching case
to handle the two different cases of the Result
type - an Ok
type with the parsed result, and an Err
type with the error (which we ignore).
Both the Ok
and the Err
types return a tuple with the Msg
as the first item, and then Cmd.none
as the second. Cmd.none
indicates that we don't want to trigger a new command as a result of this message. This makes sense in our case - because if we got a result, we're done, and if the API call failed, there's no need to try again immediately.
I'm a front end programmer after all, so the coolest part for me is always going to be implementing the view. Here's what I put together:
view : Model -> Html Msg
view model =
div [ A.style "text-align" "center" ]
[ h1 [] [ text "Elm dad jokes 😎" ]
, case model of
Loading ->
p [] [ text "Loading..." ]
Failure ->
p [ A.style "color" "red" ] [ text "Ouch, something went wrong while fetching the stuff over the network" ]
Success theText ->
pre [] [ text theText ]
]
Here, I create a containing <div />
, a heading, and then pattern match our model to create three distinct views based on which state we're in.
Namespacing attributes
Notice theA.
inA.style
? Previously, I imported all of the possible attributes into the global namespace withimport Html.Attributes exposing (..)
. That turned out to be a bit polluting to my taste. In this app, I'm namespacing them withA
, by changing my import toimport Html.Attributes as A
. This way, I don't pollute the global namespace as much, while still keeping the attribute names short when I need to use them.
We've forgotten one step - calling the Browser.sandbox
function. This time, however, since we're introducing commands into the mix, we need to call a different function - Browser.element
. It looks pretty similar, but requires another function - subscriptions
. We're not going to bother with subscriptions this time around, so let's just say we don't have any:
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
Now, we got all the pieces to call Browser.element
:
main =
Browser.element
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
}
That should be it! Let's run our app to get some laughs:
Argh - I must've missed something - I'm not getting the joke, I'm getting the entire HTML page! This is ridiculous, and not in the way I wanted it to be!
After reading the API docs, you have to specify the Accept: text/plain
HTTP header to only get the text. But how do I do that?
Http.request
The original Http.get
function I called earlier didn't support sending headers, but after heading (hah 😄) over to the very nice documentation for the elm/http
package, I came across another function that provided more flexibility - the request
function.
After a bit of trial and error, I ended up with changing my init
function to this:
init : () -> ( Model, Cmd Msg )
init _ =
( Loading
, Http.request
{ method = "GET"
, body = Http.emptyBody
, headers = [ Http.header "Accept" "text/plain" ]
, url = "https://icanhazdadjoke.com"
, expect = Http.expectString GotJoke
, timeout = Nothing
, tracker = Nothing
}
)
A few more lines of code, but still pretty manageable. After refreshing my page, I got what I had been hoping for:
Great success!
Even though that joke truly is a classic, I soon wanted more. So the next feature I wanted to implement was adding a "get a new joke" button! Let's go through the steps I needed to add this new feature.
First, I added a new type to the Msg
type:
type Msg
= GotJoke (Result Http.Error String)
| RequestNewJoke
Now my update
function is broken, since I no longer handle all cases. Let's fix that:
update : Msg -> Model -> ( Model, Cmd Msg )
update msg _ =
case msg of
GotJoke result ->
case result of
Ok text ->
( Success text, Cmd.none )
Err _ ->
( Failure, Cmd.none )
RequestNewJoke ->
( Loading, fetchDadJoke )
If we receive a RequestNewJoke
message, we set the state to Loading
, and trigger the command fetchDadJoke
. But where does that last part come from?
The fetchDadJoke
command is refactored out from our init function, and is the code that calls the API:
fetchDadJoke : Cmd Msg
fetchDadJoke =
Http.request
{ method = "GET"
, body = Http.emptyBody
, headers = [ Http.header "Accept" "text/plain" ]
, url = "https://icanhazdadjoke.com"
, expect = Http.expectString GotJoke
, timeout = Nothing
, tracker = Nothing
}
init : () -> ( Model, Cmd Msg )
init _ =
( Loading
, fetchDadJoke
)
Neat, right? I guess I could've refactored out the entire tuple, but it looks pretty nice as is, too.
Finally, we just need to add some buttons to our UI. I decided to add a retry button in case of errors, as well:
view : Model -> Html Msg
view model =
div [ A.style "text-align" "center" ]
[ h1 [] [ text "Elm dad jokes 😎" ]
, case model of
Loading ->
p [] [ text "Loading..." ]
Failure ->
div []
[ p [ A.style "color" "red" ]
[ text "Ouch, something went wrong while fetching the stuff over the network"
]
, button [ Events.onClick RequestNewJoke, A.type_ "button" ]
[ text "Try again" ]
]
Success theText ->
div []
[ pre [] [ text theText ]
, button [ Events.onClick RequestNewJoke, A.type_ "button" ]
[ text "Get another joke" ]
]
]
And that's it, really! We now have "get a new joke" support as well.
I learned a lot building this project, and I hope you did as well with following along. There's quite a few new concepts here, with Result
and Cmd
and what have you. However, it's coming together quite nicely.
This is the third app I've ever built in Elm, and the syntax is starting to finally feel... "right". I was very skeptical at first, but I think it's growing on me.
Handling HTTP requests was a breeze, but very few APIs just return simple text strings. So next, I want to integrate with a fully fledged JSON API, so I can try my hands on decoding and encoding JSON. I'm sure it'll be a hoot!
You can see the entire code for this app in this gist, and even try it out if you want.
After a bit of struggling, I even made a CodeSandbox of it:
All rights reserved © 2024