From React to Elm with a Todo Tracker

Samuel Wood
The Startup
Published in
8 min readFeb 25, 2020

A simple Todo tracker is one of the most popular entry-level projects to get started with a new language or technology. It’s one of the first challenges I took on with Elm and I remember it being quite a battle at first. Coming from the Javascript world, my brain struggled at first with various aspects of Elm. I hope to provide some clarity for others that are coming from Javascript or, more specifically, React.

Throughout this article, I’ll be framing things as if I’m teaching a React dev. I’ll try to relate things back to Javascript and explain concepts in a way that I wished I had them explained to me. I also recommend going through the official Elm docs prior to this.

Step 1: The setup

First, set up a basic Elm project with an Main.elm file in your src directory. We won’t go into detail on how to do this in this tutorial but feel free to refer to this article, this package, or anything else you need to get your project up and running.

Step 2: The imports

Let’s open our Main.elm and delete all existing code so we have a clean slate. From there, we can begin with our imports and module declaration. This is all more or less straight forward. The module declaration tells Elm what we are exposing, or exporting, from this file. The imports are modules that will be used later on.

module Main exposing (..)import Browserimport Html exposing (Html, button, div, text, input, h1)import Html.Attributes exposing (class, value)import Html.Events exposing (onClick, onInput)

Step 3: The model

Let’s create our model. Our model is similar to local state in React. This holds persistent values between re-renders. Our model can be any valid Elm data type, for example, a record (essentially a JS object) or a list (essentially a JS array). Since Elm is a statically typed language, let’s use types and type aliases to do some abstraction and help define our model.

...
-- MODEL
type alias Task = {id : Int, content: String}
type alias Model = {tasks : List Task, nextTaskId : Int, taskToBe : String }

Here we are defining Task as a type alias, and then assigning tasks in our Model as a list of the above type alias, Task. We also have a nextTaskId integer that will increment on the addition of every task and assign each new task with a custom id. Lastly, we have a taskToBe string which will keep track of the task we are currently creating (read: typing).

Now that we have a blueprint of our Model, let’s actually create an init function and use our model within our init. The closest thing to init that we have in React, is our constructor life-cycle method in the classic React.Component class structure. For now, you can think of this as our constructor.

...
init : Model --This says our init should produce type Model.
init =
{
nextTaskId = 3
, taskToBe = ""
, tasks = [
{id = 1, content = "First"}
, {id = 2, content ="Second"}
]
}

As you can see, our init model correctly corresponds with our Model type alias.

Step 4: The update

Enough with our model. Let’s get into our update. We’re going to keep this simple for now and polish it off in a few minutes. Our update function describes how our model will react to the user’s actions (essentially handles all our click and input events).

We must define what Msg's our update will handle.

type Msg =  SaveTaskToBe String | AddTask | DeleteTask Task

Let’s create our update function that includes the above Msg’s. Right now, let’s just return the existing model on each Msg and we’ll come back for them later. Our update is going to take in the type of Msg as argument msg and then our existing model will be passed in automatically if we give it the type annotation and include it in our arguments.

update : Msg -> Model -> Model 
-- ^ defines our updates arguments and return value
update msg model =
case msg of

SaveTaskToBe newTask ->
model
AddTask ->
model

DeleteTask taskToDelete ->
model

One more thing to cover here is our case of msg handler. This is similar to a JS switch statement. msg will be defined as SaveTaskToBe, AddTask, or DeleteTask. The msg will dictate which code block to execute, just like a case in a switch statement. As you can see two of our “cases” have arguments (new task and taskToDelete) while AddTask does not. These arguments and their types are dictated in our Msg type definition above. We’ll see in a second how we pass these arguments and properly call our update.

Step 5: The view

Now for our view. This is similar enough to JSX or HTML: divs, inputs, buttons, and header tags. If you don’t see it at first, pay attention to the onInput and onClick attributes that are assigned to our input and buttons. Notice how the attribute is followed by one of our Msg’s from our update functions. As you can assume, those events trigger their respective update Msg’s. One thing that confused me early on is that we never called update specifically, so how did the program know what to trigger when an event happened? The answer is that Elm maps our view, update, and init functions as per some code we’ll write in a little bit. So Elm automatically knows that these Msg’s must belong to our update function, and runs them accordingly. One more thing: if you look in our type definition of SaveTaskToBe, you will see that it has an argument String, but we don’t actually specify what that string is in our call. This happens automatically, as Elm knows to pass the input’s value to it’s respective event msg. For DeleteTask, we know we must pass it an argument Task, so that’s exactly what we’ve done.

view : Model -> Html Msg -- Type Definition
view model =
div []
[
div [] [
input [value model.taskToBe, onInput SaveTaskToBe] []
, button [onClick AddTask] [text "+"]
]
, h1 [] [text "Tasks:"]
, div [] (List.map (\item -> viewTask item) (List.reverse model.tasks)
]viewTask : Task -> Html Msg
viewTask item =
div []
[
button [onClick (DeleteTask item)] [ text "-"]
, div [] [text item.content]
]

You may also see that we have abstracted each task into it’s own viewTask function. I did this readability‘s sake. I find it quite handy that we can abstract certain sections of the view into their own functions.

Step 6: Return of the update

Remember how there wasn’t any specific Msg logic in our update function? Let’s remedy that!
TIP:
{model | taskToBe = newTask} is just like {...model, taskToBe: newTask} in Javascript...

Firstly, let’s add some utility functions that we will use in our update logic.

...
doesNotMatch : Task -> Task -> Bool
doesNotMatch taskToDelete task =
not (taskToDelete.id == task.id)
isValidTask : String -> Bool
isValidTask string =
String.length string > 0
...

Then…

...
update : Msg -> Model -> Model
update msg model =
case msg of

SaveTaskToBe newTask ->
{model | taskToBe = newTask}
AddTask ->
{model | tasks = if (isValidTask model.taskToBe)
then {id = model.nextTaskId, content = model.taskToBe} :: model.tasks
else model.tasks

, taskToBe = "", nextTaskId = model.nextTaskId + 1}

DeleteTask taskToDelete ->
{model | tasks = List.filter (doesNotMatch taskToDelete) model.tasks}
...

First off, our SaveTaskToBe is quite simple. We are updating our model by storing the current input’s value in our taskToBe. Remember that newTask is simply the value of our input (e.target.value in JS). We’ll use this taskToBe in a moment.

Secondly, AddTask is doing a few things. Most importantly, it must add a new task. So it assigns tasks to an if-statement. That if statement first determines taskToBe isn’t empty, by passing it to our utility function isValidTask. If it isn’t empty (aka is valid), it runs: {id = model.nextTaskId, content = model.taskToBe} :: model.tasks. This is essentially equal to model.taskToBe.push({id: model.nextTaskId, content: model.taskToBe}); in JS. In English, it push taskToBe to our tasks list that we have defined in our model. If, however, the taskToBe is empty (aka not valid), it simply assigns tasks to the existing model.tasks, not changing our list of tasks whatsoever. We also need to do some cleanup after adding our task. That’s why you see , taskToBe = “”, nextTaskId = model.nextTaskId + 1}. This just resets taskToBe to an empty string, and increments our unique nextTaskId. The nextTaskId will come in handy when dealing with deletion.

Lastly, the DeleteTask takes in a taskToDelete argument. We need to remove said task from our model.tasks. We are going to a use the List.filter function to manage that. How this works is by passing every element through a test, and only keeping the element in the list if it passed said test. In this case, our test will be our utility function doesNotMatch. We are going to be comparing every task in our list with the taskToDelete id. If the id’s don’t match, our doesNotMatch returns true, leaving that item in our list. If the id’s do match, it returns false, telling the filter function to filter that item out of our list. And with that, we’ve successfully removed an element from model.tasks.

Step 7: Main

The final thing we need to do for our Elm module it to connect our init, update, and view functions. For this, we’ll use the function sandbox from our import Browser. We use it like this:

main =
Browser.sandbox { init = init, update = update, view = view }

There you have it folks. A fully function Todo Tracker built in Elm. If you run elm reactor, and navigate to localhost:8000/src/Main.elm, you’ll see your masterpiece.

I’ve added class names and have a hosted styles sheet if you want to make things pretty real quick. You can import my style-sheet into your HTML file if you so desire:

<link rel=”stylesheet” type=”text/css” href=”https://drive.google.com/uc?export=view&id=1wHUHDy3Z7thi_d1unu0-SLoFyPS2FuM5"
/>

Final File:

module Main exposing (..)import Browser
import Html exposing (Html, button, div, text, input, h1)
import Html.Attributes exposing (class, value)
import Html.Events exposing (onClick, onInput)
-- MODEL
type alias Task = {id : Int, content: String}
type alias Model = {tasks : List Task, nextTaskId : Int, taskToBe : String }
init : Model
init =
{
nextTaskId = 3
, taskToBe = ""
, tasks = [{id = 1, content = "First"}, {id=2, content ="Second"}]
}
-- UPDATE
type Msg = SaveTaskToBe String | AddTask | DeleteTask Task
doesNotMatch : Task -> Task -> Bool
doesNotMatch taskToDelete task =
not (taskToDelete.id == task.id)
isValidTask : String -> Bool
isValidTask string =
String.length string > 0
update : Msg -> Model -> Model
update msg model =
case msg of

SaveTaskToBe newTask ->
{model | taskToBe = newTask}
AddTask ->
{model | tasks = if isValidTask model.taskToBe
then {id = model.nextTaskId, content = model.taskToBe} :: model.tasks
else model.tasks
, taskToBe = "", nextTaskId = model.nextTaskId + 1}

DeleteTask taskToDelete ->
{model | tasks = List.filter (doesNotMatch taskToDelete) model.tasks}

-- VIEW
view : Model -> Html Msg
view model =
div [class "MainContainer"]
[
div [class "InputContainer"] [
input [class "Input", value model.taskToBe, onInput SaveTaskToBe] []
, button [ class "AddTask", onClick AddTask] [text "+"]
]
, h1 [] [text "Tasks:"]
, div [] (List.map (\item -> viewTask item) (List.reverse model.tasks))
]
viewTask : Task -> Html Msg
viewTask item =
div [class "TaskContainer"]
[
button [class "Delete", onClick (DeleteTask item)] [ text "-"]
, div [class "TaskText"] [text item.content]
]
-- MAIN
main =
Browser.sandbox { init = init, update = update, view = view }

Thanks for reading this far! Feel free to leave any comments you may have, good or bad. I appreciate it all!

--

--