This tutorial includes: - 7 progressive lessons covering Elm fundamentals - Exercises with starter code - Solutions for exercises - Final project: Task Manager app with localStorage persistence Topics covered: - Basic syntax and types - Functions and functional programming concepts - The Elm Architecture (Model-View-Update) - Lists and Maybe types - HTTP requests and JSON decoding - Ports for JavaScript interop
730 lines
16 KiB
Markdown
730 lines
16 KiB
Markdown
# Lesson 4: The Elm Architecture (TEA)
|
||
|
||
## Learning Goals
|
||
|
||
By the end of this lesson, you will:
|
||
- Understand the Model-View-Update pattern
|
||
- Handle user input events
|
||
- Build interactive applications
|
||
- Understand how Elm inspired Redux
|
||
|
||
## What is The Elm Architecture?
|
||
|
||
The Elm Architecture (TEA) is a pattern for building web applications. It has three parts:
|
||
|
||
1. **Model** - Your application's state (the data)
|
||
2. **View** - A function that turns the model into HTML
|
||
3. **Update** - A function that updates the model based on messages
|
||
|
||
```
|
||
┌──────────────────────────────────────────┐
|
||
│ │
|
||
│ User clicks button │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌──────────────┐ │
|
||
│ │ Message │ │
|
||
│ └──────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌──────────────┐ ┌──────────────┐ │
|
||
│ │ Update │───▶│ Model │ │
|
||
│ └──────────────┘ └──────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌──────────────┐ │
|
||
│ │ View │ │
|
||
│ └──────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌──────────────┐ │
|
||
│ │ HTML │───┘
|
||
│ └──────────────┘
|
||
│
|
||
└──────────────────────────────────────────┘
|
||
```
|
||
|
||
### Redux Connection
|
||
|
||
If you've used Redux, you already know TEA!
|
||
|
||
| Elm | Redux |
|
||
|-----|-------|
|
||
| Model | State/Store |
|
||
| Msg | Action |
|
||
| Update | Reducer |
|
||
| View | React Component |
|
||
|
||
## Your First Interactive App: Counter
|
||
|
||
Let's build a counter that you can increment and decrement.
|
||
|
||
Create a new file `src/Counter.elm`:
|
||
|
||
```elm
|
||
module Counter exposing (main)
|
||
|
||
import Browser
|
||
import Html exposing (Html, button, div, text)
|
||
import Html.Events exposing (onClick)
|
||
|
||
|
||
-- MODEL
|
||
|
||
type alias Model =
|
||
{ count : Int
|
||
}
|
||
|
||
|
||
init : Model
|
||
init =
|
||
{ count = 0
|
||
}
|
||
|
||
|
||
-- UPDATE
|
||
|
||
type Msg
|
||
= Increment
|
||
| Decrement
|
||
|
||
|
||
update : Msg -> Model -> Model
|
||
update msg model =
|
||
case msg of
|
||
Increment ->
|
||
{ model | count = model.count + 1 }
|
||
|
||
Decrement ->
|
||
{ model | count = model.count - 1 }
|
||
|
||
|
||
-- VIEW
|
||
|
||
view : Model -> Html Msg
|
||
view model =
|
||
div []
|
||
[ button [ onClick Decrement ] [ text "-" ]
|
||
, div [] [ text (String.fromInt model.count) ]
|
||
, button [ onClick Increment ] [ text "+" ]
|
||
]
|
||
|
||
|
||
-- MAIN
|
||
|
||
main =
|
||
Browser.sandbox
|
||
{ init = init
|
||
, update = update
|
||
, view = view
|
||
}
|
||
```
|
||
|
||
Run it with:
|
||
```bash
|
||
elm reactor
|
||
```
|
||
Then open http://localhost:8000/src/Counter.elm
|
||
|
||
### Breaking It Down
|
||
|
||
#### 1. The Model
|
||
|
||
```elm
|
||
type alias Model =
|
||
{ count : Int
|
||
}
|
||
|
||
init : Model
|
||
init =
|
||
{ count = 0
|
||
}
|
||
```
|
||
|
||
The model is just a record holding your state. `init` is the initial state.
|
||
|
||
**JavaScript/Redux equivalent:**
|
||
```javascript
|
||
const initialState = { count: 0 };
|
||
```
|
||
|
||
#### 2. Messages (Msg)
|
||
|
||
```elm
|
||
type Msg
|
||
= Increment
|
||
| Decrement
|
||
```
|
||
|
||
Messages describe things that can happen. They're like Redux action types, but type-safe.
|
||
|
||
**JavaScript/Redux equivalent:**
|
||
```javascript
|
||
const INCREMENT = 'INCREMENT';
|
||
const DECREMENT = 'DECREMENT';
|
||
```
|
||
|
||
#### 3. The Update Function
|
||
|
||
```elm
|
||
update : Msg -> Model -> Model
|
||
update msg model =
|
||
case msg of
|
||
Increment ->
|
||
{ model | count = model.count + 1 }
|
||
|
||
Decrement ->
|
||
{ model | count = model.count - 1 }
|
||
```
|
||
|
||
Takes a message and the current model, returns a new model. This is a **pure function** - no side effects!
|
||
|
||
**JavaScript/Redux equivalent:**
|
||
```javascript
|
||
function reducer(state, action) {
|
||
switch (action.type) {
|
||
case INCREMENT:
|
||
return { ...state, count: state.count + 1 };
|
||
case DECREMENT:
|
||
return { ...state, count: state.count - 1 };
|
||
default:
|
||
return state;
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 4. The View Function
|
||
|
||
```elm
|
||
view : Model -> Html Msg
|
||
view model =
|
||
div []
|
||
[ button [ onClick Decrement ] [ text "-" ]
|
||
, div [] [ text (String.fromInt model.count) ]
|
||
, button [ onClick Increment ] [ text "+" ]
|
||
]
|
||
```
|
||
|
||
A pure function that takes the model and returns HTML. The `Html Msg` type means "HTML that can produce Msg values."
|
||
|
||
**React equivalent:**
|
||
```jsx
|
||
function Counter({ count, dispatch }) {
|
||
return (
|
||
<div>
|
||
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
|
||
<div>{count}</div>
|
||
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
#### 5. Wiring It Together
|
||
|
||
```elm
|
||
main =
|
||
Browser.sandbox
|
||
{ init = init
|
||
, update = update
|
||
, view = view
|
||
}
|
||
```
|
||
|
||
`Browser.sandbox` connects everything. Elm handles:
|
||
- Calling `view` whenever the model changes
|
||
- Routing user events to `update`
|
||
- Re-rendering efficiently (virtual DOM)
|
||
|
||
## HTML in Elm
|
||
|
||
Every HTML element follows this pattern:
|
||
```elm
|
||
element attributes children
|
||
```
|
||
|
||
```elm
|
||
-- <div class="container">Hello</div>
|
||
div [ class "container" ] [ text "Hello" ]
|
||
|
||
-- <button id="submit" disabled>Submit</button>
|
||
button [ id "submit", disabled True ] [ text "Submit" ]
|
||
|
||
-- <input type="text" value="hello" />
|
||
input [ type_ "text", value "hello" ] []
|
||
```
|
||
|
||
Note: Some attributes like `type` use underscores (`type_`) because they conflict with Elm keywords.
|
||
|
||
### Common Attributes
|
||
|
||
```elm
|
||
import Html.Attributes exposing (..)
|
||
|
||
-- Styling
|
||
class "my-class"
|
||
style "color" "red"
|
||
id "my-id"
|
||
|
||
-- Form inputs
|
||
value "text"
|
||
placeholder "Enter text..."
|
||
disabled True
|
||
checked True
|
||
|
||
-- Links and images
|
||
href "https://elm-lang.org"
|
||
src "image.png"
|
||
alt "Description"
|
||
```
|
||
|
||
### Common Events
|
||
|
||
```elm
|
||
import Html.Events exposing (..)
|
||
|
||
onClick Msg -- Button clicks
|
||
onInput (String -> Msg) -- Text input
|
||
onSubmit Msg -- Form submission
|
||
onMouseEnter Msg -- Mouse enter
|
||
onMouseLeave Msg -- Mouse leave
|
||
```
|
||
|
||
## Example 2: Text Input
|
||
|
||
```elm
|
||
module TextInput exposing (main)
|
||
|
||
import Browser
|
||
import Html exposing (Html, div, input, text)
|
||
import Html.Attributes exposing (placeholder, value)
|
||
import Html.Events exposing (onInput)
|
||
|
||
|
||
-- MODEL
|
||
|
||
type alias Model =
|
||
{ content : String
|
||
}
|
||
|
||
|
||
init : Model
|
||
init =
|
||
{ content = ""
|
||
}
|
||
|
||
|
||
-- UPDATE
|
||
|
||
type Msg
|
||
= Change String
|
||
|
||
|
||
update : Msg -> Model -> Model
|
||
update msg model =
|
||
case msg of
|
||
Change newContent ->
|
||
{ model | content = newContent }
|
||
|
||
|
||
-- VIEW
|
||
|
||
view : Model -> Html Msg
|
||
view model =
|
||
div []
|
||
[ input
|
||
[ placeholder "Type something..."
|
||
, value model.content
|
||
, onInput Change
|
||
]
|
||
[]
|
||
, div [] [ text ("You typed: " ++ model.content) ]
|
||
, div [] [ text ("Length: " ++ String.fromInt (String.length model.content)) ]
|
||
]
|
||
|
||
|
||
main =
|
||
Browser.sandbox
|
||
{ init = init
|
||
, update = update
|
||
, view = view
|
||
}
|
||
```
|
||
|
||
### Key Point: Messages with Data
|
||
|
||
```elm
|
||
type Msg
|
||
= Change String -- This message carries a String!
|
||
|
||
-- onInput sends the input's value with the message
|
||
onInput Change -- When input changes, send: Change "new value"
|
||
|
||
-- In update, extract the data
|
||
Change newContent ->
|
||
{ model | content = newContent }
|
||
```
|
||
|
||
## Example 3: Todo List
|
||
|
||
A more complex example with a list of items:
|
||
|
||
```elm
|
||
module TodoList exposing (main)
|
||
|
||
import Browser
|
||
import Html exposing (Html, button, div, input, li, text, ul)
|
||
import Html.Attributes exposing (placeholder, value)
|
||
import Html.Events exposing (onClick, onInput)
|
||
|
||
|
||
-- MODEL
|
||
|
||
type alias Model =
|
||
{ todos : List String
|
||
, newTodo : String
|
||
}
|
||
|
||
|
||
init : Model
|
||
init =
|
||
{ todos = []
|
||
, newTodo = ""
|
||
}
|
||
|
||
|
||
-- UPDATE
|
||
|
||
type Msg
|
||
= UpdateNewTodo String
|
||
| AddTodo
|
||
| RemoveTodo Int
|
||
|
||
|
||
update : Msg -> Model -> Model
|
||
update msg model =
|
||
case msg of
|
||
UpdateNewTodo text ->
|
||
{ model | newTodo = text }
|
||
|
||
AddTodo ->
|
||
if String.isEmpty model.newTodo then
|
||
model
|
||
else
|
||
{ model
|
||
| todos = model.todos ++ [ model.newTodo ]
|
||
, newTodo = ""
|
||
}
|
||
|
||
RemoveTodo index ->
|
||
{ model
|
||
| todos = removeAt index model.todos
|
||
}
|
||
|
||
|
||
removeAt : Int -> List a -> List a
|
||
removeAt index list =
|
||
List.take index list ++ List.drop (index + 1) list
|
||
|
||
|
||
-- VIEW
|
||
|
||
view : Model -> Html Msg
|
||
view model =
|
||
div []
|
||
[ div []
|
||
[ input
|
||
[ placeholder "Add a todo..."
|
||
, value model.newTodo
|
||
, onInput UpdateNewTodo
|
||
]
|
||
[]
|
||
, button [ onClick AddTodo ] [ text "Add" ]
|
||
]
|
||
, ul [] (List.indexedMap viewTodo model.todos)
|
||
]
|
||
|
||
|
||
viewTodo : Int -> String -> Html Msg
|
||
viewTodo index todo =
|
||
li []
|
||
[ text todo
|
||
, button [ onClick (RemoveTodo index) ] [ text "x" ]
|
||
]
|
||
|
||
|
||
main =
|
||
Browser.sandbox
|
||
{ init = init
|
||
, update = update
|
||
, view = view
|
||
}
|
||
```
|
||
|
||
### Key Concepts
|
||
|
||
1. **List.indexedMap** - Like map, but gives you the index too
|
||
2. **Messages with multiple types of data** - `RemoveTodo Int` carries which item to remove
|
||
3. **Helper functions** - `removeAt` and `viewTodo` keep code organized
|
||
|
||
## Exercise 4.1: Add Features to Counter
|
||
|
||
Enhance the counter with:
|
||
1. A "Reset" button that sets count to 0
|
||
2. A "Double" button that doubles the count
|
||
|
||
<details>
|
||
<summary>Solution</summary>
|
||
|
||
```elm
|
||
type Msg
|
||
= Increment
|
||
| Decrement
|
||
| Reset
|
||
| Double
|
||
|
||
|
||
update : Msg -> Model -> Model
|
||
update msg model =
|
||
case msg of
|
||
Increment ->
|
||
{ model | count = model.count + 1 }
|
||
|
||
Decrement ->
|
||
{ model | count = model.count - 1 }
|
||
|
||
Reset ->
|
||
{ model | count = 0 }
|
||
|
||
Double ->
|
||
{ model | count = model.count * 2 }
|
||
|
||
|
||
view : Model -> Html Msg
|
||
view model =
|
||
div []
|
||
[ button [ onClick Decrement ] [ text "-" ]
|
||
, div [] [ text (String.fromInt model.count) ]
|
||
, button [ onClick Increment ] [ text "+" ]
|
||
, button [ onClick Reset ] [ text "Reset" ]
|
||
, button [ onClick Double ] [ text "Double" ]
|
||
]
|
||
```
|
||
|
||
</details>
|
||
|
||
## Exercise 4.2: Temperature Converter
|
||
|
||
Build a Celsius to Fahrenheit converter:
|
||
- Input field for Celsius
|
||
- Display the Fahrenheit equivalent
|
||
- Formula: F = C × 9/5 + 32
|
||
|
||
<details>
|
||
<summary>Solution</summary>
|
||
|
||
```elm
|
||
module TempConverter exposing (main)
|
||
|
||
import Browser
|
||
import Html exposing (Html, div, input, text)
|
||
import Html.Attributes exposing (placeholder, value)
|
||
import Html.Events exposing (onInput)
|
||
|
||
|
||
type alias Model =
|
||
{ celsius : String
|
||
}
|
||
|
||
|
||
init : Model
|
||
init =
|
||
{ celsius = ""
|
||
}
|
||
|
||
|
||
type Msg
|
||
= UpdateCelsius String
|
||
|
||
|
||
update : Msg -> Model -> Model
|
||
update msg model =
|
||
case msg of
|
||
UpdateCelsius value ->
|
||
{ model | celsius = value }
|
||
|
||
|
||
celsiusToFahrenheit : Float -> Float
|
||
celsiusToFahrenheit c =
|
||
c * 9 / 5 + 32
|
||
|
||
|
||
view : Model -> Html Msg
|
||
view model =
|
||
let
|
||
fahrenheit =
|
||
case String.toFloat model.celsius of
|
||
Just c ->
|
||
String.fromFloat (celsiusToFahrenheit c) ++ "°F"
|
||
|
||
Nothing ->
|
||
"Enter a valid number"
|
||
in
|
||
div []
|
||
[ input
|
||
[ placeholder "Celsius"
|
||
, value model.celsius
|
||
, onInput UpdateCelsius
|
||
]
|
||
[]
|
||
, text " °C = "
|
||
, text fahrenheit
|
||
]
|
||
|
||
|
||
main =
|
||
Browser.sandbox
|
||
{ init = init
|
||
, update = update
|
||
, view = view
|
||
}
|
||
```
|
||
|
||
</details>
|
||
|
||
## Exercise 4.3: Signup Form
|
||
|
||
Build a signup form with:
|
||
- Name field
|
||
- Email field
|
||
- Password field
|
||
- "Sign Up" button
|
||
- Display "Welcome, {name}!" after clicking Sign Up
|
||
|
||
<details>
|
||
<summary>Solution</summary>
|
||
|
||
```elm
|
||
module Signup exposing (main)
|
||
|
||
import Browser
|
||
import Html exposing (Html, button, div, input, text)
|
||
import Html.Attributes exposing (placeholder, type_, value)
|
||
import Html.Events exposing (onClick, onInput)
|
||
|
||
|
||
type alias Model =
|
||
{ name : String
|
||
, email : String
|
||
, password : String
|
||
, submitted : Bool
|
||
}
|
||
|
||
|
||
init : Model
|
||
init =
|
||
{ name = ""
|
||
, email = ""
|
||
, password = ""
|
||
, submitted = False
|
||
}
|
||
|
||
|
||
type Msg
|
||
= UpdateName String
|
||
| UpdateEmail String
|
||
| UpdatePassword String
|
||
| Submit
|
||
|
||
|
||
update : Msg -> Model -> Model
|
||
update msg model =
|
||
case msg of
|
||
UpdateName value ->
|
||
{ model | name = value }
|
||
|
||
UpdateEmail value ->
|
||
{ model | email = value }
|
||
|
||
UpdatePassword value ->
|
||
{ model | password = value }
|
||
|
||
Submit ->
|
||
{ model | submitted = True }
|
||
|
||
|
||
view : Model -> Html Msg
|
||
view model =
|
||
if model.submitted then
|
||
div [] [ text ("Welcome, " ++ model.name ++ "!") ]
|
||
else
|
||
div []
|
||
[ div []
|
||
[ input
|
||
[ placeholder "Name"
|
||
, value model.name
|
||
, onInput UpdateName
|
||
]
|
||
[]
|
||
]
|
||
, div []
|
||
[ input
|
||
[ placeholder "Email"
|
||
, type_ "email"
|
||
, value model.email
|
||
, onInput UpdateEmail
|
||
]
|
||
[]
|
||
]
|
||
, div []
|
||
[ input
|
||
[ placeholder "Password"
|
||
, type_ "password"
|
||
, value model.password
|
||
, onInput UpdatePassword
|
||
]
|
||
[]
|
||
]
|
||
, button [ onClick Submit ] [ text "Sign Up" ]
|
||
]
|
||
|
||
|
||
main =
|
||
Browser.sandbox
|
||
{ init = init
|
||
, update = update
|
||
, view = view
|
||
}
|
||
```
|
||
|
||
</details>
|
||
|
||
## Key Takeaways
|
||
|
||
1. **TEA has three parts:** Model, View, Update
|
||
2. **Model** holds all your application state
|
||
3. **View** is a pure function: Model → HTML
|
||
4. **Update** is a pure function: Msg → Model → Model
|
||
5. **Messages** describe events and can carry data
|
||
6. **Browser.sandbox** wires everything together
|
||
7. **HTML is just functions** with attributes and children
|
||
|
||
## The Elm Architecture Benefits
|
||
|
||
1. **No unexpected state changes** - All updates go through update
|
||
2. **Time-travel debugging** - Elm debugger lets you replay messages
|
||
3. **Easy testing** - Pure functions with no side effects
|
||
4. **Predictable** - Given a model and message, you know the result
|
||
|
||
## What's Next?
|
||
|
||
In [Lesson 5](05-lists-maybe.md), we'll dive deeper into:
|
||
- Working with Lists
|
||
- The Maybe type for handling missing values
|
||
- Pattern matching techniques
|
||
- Common List operations
|
||
|
||
---
|
||
|
||
[← Previous: Lesson 3](03-functions.md) | [Next: Lesson 5 - Lists & Maybe →](05-lists-maybe.md)
|