Creating a todo app in Elm

Creating a todo app in Elm

December 30, 2019
10 min read

This is a step-by-step guide to creating your own TODO-app in Elm!

I'm going to do something really different - I'm going to learn a new language in the open, and learn by trying to teach what I've learned step by step. The language I'm trying to learn is Elm.

In the last article in this series, we went through setting up an Elm developer environment, as well as creating a very "hello world"-y counter app.

In this article, we're going to create something a tiny bit more advanced - the trusty old todo app.

Start with the model

I think Elm does a lot of things well, but one of my favorite things is that it makes you think about how you're going to model your state. Since we're creating a todo app, it makes sense to start out with modeling a todo:

type alias Todo =
    { text : String
    , completed : Bool

That is - a todo is a record (similar to a JavaScript object) with a descriptive text and a completed flag.

Now, we don't want to handle a single todo, but a list of them. So our model might look like this:

type alias Model = 
    List Todo

A List in Elm is a linked list implementation of an array, and is well documented. It's what is created when you write code like list = [1,2,3], so it looked like just what I needed.

Our model is still lacking, though. In order to add todos, we need to keep track of the text in our "add todo" input as well. Therefore, we need to use a record!

type alias Model = 
    { todos: List Todo
    , inputText: String

Now we're cooking!

Mapping out the available actions

So we've been able to create a state model. Next up is creating a list of possible actions that might happen in our application. Let's create a type that enumerates all those possibilities.

type Message 
    = AddTodo
    | RemoveTodo Int
    | ToggleTodo Int
    | ChangeInput String

Here, we create four distinct actions - and most accept an argument as well. So the AddTodo message will accept no arguments, while the RemoveTodo will accept the index to remove as an argument and so forth. I think this is what you call a parameterized custom type, but don't let that bother you for a second 😄 Just know that the word following the message type is the type of the first argument. If you added a second type after that, it would indicate that the message would expect two arguments, and so forth!

type vs type alias
If you paid meticulous attention to the above examples, you noticed that we wrote type alias when we specified our model, and type when we specified our message type. Why is that?

If I've understood the FAQ correctly, a type alias is a "shortcut" for a particular type, while a type is an actual distinct type. I think you could pattern match a type, but not a type alias. We specify the type of functions like update and view with type aliases.

Writing the business logic

I giggle every time I call the logic in my todo app for "business logic", but I guess it's what it is. No matter what you call it though, we should implement it via the update method.

If you don't remember from the last post of our series, you can think of this method as the "reducer" of a Redux application. It gets called whenever you trigger an action in your app, receives the old state model and expects you to return the updated state model.

We're going to handle each of the possible messages with a case .. of expression - which is a way to "pattern match". I still call it a "fancy switch statement". For our app, it looks like this:

update : Message -> Model -> Model
update message model
    = case message of
        AddTodo -> 
            { model 
                | todos = addToList model.inputText model.todos
                , inputText = ""
        RemoveTodo index -> 
            { model | todos = removeFromList index model.todos }
        ToggleTodo index ->
            { model | todos = toggleAtIndex index model.todos }
        ChangeInput input ->
            { model | inputText = input }

This is a tad bit simplified, so let's step through it one case at a time.

Handling AddTodo

First, we handle the AddTodo message. We use the { model | something } syntax to copy the existing model, and then overriding any fields to the right of the |. In this particular instance, we wouldn't have needed it, since we change the entire state - but by doing it anyways, we make our model easier to extend at a later point in time.

We get the new todos value by calling this mystical function addToList, which is called with the input text and the existing todos list. But how does that function look like?

addToList : String -> List Todo -> List Todo
addToList input todos =
    todos ++ [{ text = input, completed = False }]

addToList accepts a string input text and a list of todos, and returns a new list of todos. We append the old list with a list containing the new todo by using the ++ operator.

In JavaScript, this function would've looked like this:

const addToList = input => todos => [
  { text: input, completed: true },

We could've inlined this as well, but extracting a function looked a bit cleaner to me 🤷‍♂️

Handling RemoveTodo

The next message to handle is RemoveTodo. We're passed the index as an argument, and we pass both the index and the existing list as arguments to the removeAtIndex function. It looks like so:

removeFromList : Int -> List Todo -> List Todo
removeFromList index list =
    List.take index list ++ List.drop (index + 1) list

Here, we use two list functions called List.take and List.drop to construct two new lists - one that includes all items up to (but not including) the index specified, and one that includes all items from the item after the provided index. Finally, we concatenate the two lists with the ++ operator.

I'm sure there are more clever ways to do this, but that's what I came up with. 🙈

Handling ToggleTodo

ToggleTodo is pretty similar to the previous one. It calls the toggleAtIndex function, which looks like this:

toggleAtIndex : Int -> List Todo -> List Todo
toggleAtIndex indexToToggle list =
    List.indexedMap (\currentIndex todo -> 
        if currentIndex == indexToToggle then 
            { todo | completed = not todo.completed } 
    ) list

Here, we use the indexedMap list function to loop through all the items, and toggling the completed flag. Note that we're passing an anonymous function to the indexedMap function - those have to be prefaced by a \ (backslash). Supposedly, the backslash was chosen because it resembles a λ character - and it denotes a lambda function. It might not make a lot of sense, but it's a nice way to remember to add it! 😄

The JavaScript version of the same could look like this:

const toggleAtIndex = indexToToggle => todos =>, currentIndex) => 
    indexToToggle === currentIndex 
      ? { ...todo, completed: !todo.completed } 
      : todo

Handling ChangeInput

The last message to handle is the simplest one, really. The ChangeInput receives the updated input as an argument, and we return the model with an updated inputText field in response.

Implementing the view

We've designed a good state model, outlined all possible actions and implemented how they will change the model. Now, all that's left to do is to put all of this on screen!

As with the counter example in the last article, we implement the view function. It looks like this:

view : Model -> Html Message
view model =
    Html.form [ onSubmit AddTodo ]
        [ h1 [] [ text "Todos in Elm" ]
        , input [ value model.inputText, onInput ChangeInput, placeholder "What do you want to do?" ] []
        , if List.isEmpty model.todos then
            p [] [ text "The list is clean 🧘‍♀️" ]
            ol [] List.indexedMap viewTodo model.todos

Here, we create a form with an h1 tag, an input for adding new todos, and a list of todos. If there isn't any todos in your list, we let you know you're done for now.

We've pulled the "render a todo" logic into its own helper function, viewTodo. We call it for each of the todos in model.todos with the List.indexedMap utility we used earlier. It looks like this:

viewTodo : Int -> Todo -> Html Message
viewTodo index todo =
        [ style "text-decoration"
            (if todo.completed then
        [ text todo.text
        , button [ type_ "button", onClick (ToggleTodoCompleted index) ] [ text "Toggle" ]
        , button [ type_ "button", onClick (RemoveTodo index) ] [ text "Delete" ]

Here, we create a list item with the todo text, and buttons for toggling and removing the list. There's a few things here I thought I'd explain:

First off, notice that attributes that happen to be a reserved word in Elm is suffixed with _ - like type_ in the buttons.

Second, notice how you can specify inline styles. It's very verbose, but you could refactor most of that if it's becoming bothersome. For now that's fine.

Speaking of attributes, I want to do bring your attention to the fact that all HTML attributes are functions! That took me a bit by surprise to begin with, but once you "get it", the syntax makes a lot more sense!

Adding a feature: Filters!

In a typical project, you don't write complete new UIs - you add features to them. So let's add one right now.

I want to filter out which tasks are done, and what's remaining. Let's start by creating the type definition for a filter, with all its possible state.

type Filter
    = All
    | Completed
    | Remaining

Next, let's add a field to our model!

type alias Model = 
    { todos: List Todo
    , inputText: String
    , filter: Filter

The init function complains that we haven't specified an initial value for the new filter value, so let's add that as well:

init =
    { todos = []
    , inputText = ""
    , filter = All

We also need to specify a new message for changing the filter!

type Message
    = AddTodo
    | RemoveTodo Int
    | ToggleTodo Int
    | ChangeInput String
    | ChangeFilter Filter

Now our update function complains that we haven't handled all possible cases for the Message type. Implementing it is pretty similar to the ChangeInput case!

update : Message -> Model -> Model
update message model
    = case message of
        -- all the other cases are truncated for brevity
        ChangeFilter filter ->
            { model | filter = filter }

Finally, we need to change the UI a bit. First, let's create few functions for creating the "select a filter" UI:

type alias RadioWithLabelProps =
    { filter : Filter
    , label : String
    , name : String
    , checked : Bool

viewRadioWithLabel : RadioWithLabelProps -> Html Message
viewRadioWithLabel config =
    label []
        [ input 
            [ type_ "radio"
            , name
            , checked config.checked
            , onClick (ChangeFilter config.filter) 
            ] []
        , text config.label 

viewSelectFilter : Filter -> Html Message
viewSelectFilter filter =
    fieldset []
        [ legend [] [ text "Current filter" ]
        , viewRadioWithLabel 
            { filter = All
            , name = "filter"
            , checked = filter == All
            , label = "All items" 
        , viewRadioWithLabel 
            { filter = Completed
            , name = "filter"
            , checked = filter == Completed
            , label = "Completed items" 
        , viewRadioWithLabel 
            { filter = Remaining
            , name = "filter"
            , checked = filter == Remaining
            , label = "Remaining items"

Woah, that was a lot! Let's go through it step by step:

First, let's look at the viewSelectFilter function. It accepts the current filter, and returns a fieldset with a legend and three new "nested views" I've named viewRadioWithLabel. Each of these radio buttons are passed a record with four different arguments.

The viewRadioWithLabel function is pretty simple, too. It renders a label with an input inside of it, as well as the actual label text. It sets the correct attributes on the <input /> element, and adds an onClick event listener that triggers the ChangeFilter message.

Note that we gave the viewRadioWithLabel function a single record as its argument (complete with its own type alias), instead of currying four different arguments. I think that makes it much easier to reason about - even if you can't partially apply stuff the same way.

Finally, we add the viewSelectFilter to our main view function, and apply the actual filtering to our list!

view : Model -> Html Message
view model =
    Html.form [ onSubmit AddTodo ]
        [ h1 [] [ text "Todos in Elm" ]
        , input [ value model.inputText, onInput ChangeInput, placeholder "What do you want to do?" ] []
        , viewSelectFilter model.filter
        , if List.isEmpty model.todos then
            p [] [ text "The list is clean 🧘‍♀️" ]
            ol [] model.todos
                |> List.filter (applyFilter model.filter)
                |> List.indexedMap viewTodo

See what's happened where we list out our todos? We're using this new fancy operator |>, which lets us apply several functions to our list one at a time.

Let's look at the applyFilter function as well - it's pretty straight forward.

applyCurrentFilter : Filter -> Todo -> Bool
applyCurrentFilter filter todo =
    case filter of
        All ->

        Completed ->

        Remaining ->
            not todo.completed

And that's it! We've now added a completely new feature to our application!

So what have we learned?

Elm is starting to show its strengths as we make our applications more complex. There is a lot of lines of code to deal with, but it's all pretty easy once you get used to the syntax.

Creating complex UIs can be simplified by splitting out view functions as you need them. They're kind of like React components in many ways, but more specialized and less "reusable" by design. I like it!

While working through this app, I got a lot of assistance from the lovely people on the Elm Slack. I just gotta give it to this community - it's filled with so much love, compassion and helpful people. Thank you for taking the time to explain a lot of functional concepts to a complete beginner!

So what's next? I think it's about time to look into fetching some data and displaying it somehow. I feel I have gone through enough of the syntax so that I feel comfortable diving into some of the more exciting parts of Elm!

Did you learn anything from following along? Do you have questions? Or comments on how I could drastically simplify something? Please let me know, and I'll try my best to reply.

All rights reserved © 2024