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.
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!
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 wrotetype alias
when we specified our model, andtype
when we specified our message type. Why is that?
If I've understood the FAQ correctly, atype alias
is a "shortcut" for a particular type, while atype
is an actual distinct type. I think you could pattern match a type, but not a type alias. We specify the type of functions likeupdate
andview
with type aliases.
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.
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 => [
...todos,
{ text: input, completed: true },
];
We could've inlined this as well, but extracting a function looked a bit cleaner to me 🤷♂️
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. 🙈
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 }
else
todo
) 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 =>
todos.map((todo, currentIndex) =>
indexToToggle === currentIndex
? { ...todo, completed: !todo.completed }
: todo
);
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.
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 🧘♀️" ]
else
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 =
li
[ style "text-decoration"
(if todo.completed then
"line-through"
else
"none"
)
]
[ 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!
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 config.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 🧘♀️" ]
else
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 ->
True
Completed ->
todo.completed
Remaining ->
not todo.completed
And that's it! We've now added a completely new feature to our application!
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