Initial commit: Elm tutorial for JavaScript developers
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
This commit is contained in:
729
lessons/04-tea.md
Normal file
729
lessons/04-tea.md
Normal file
@@ -0,0 +1,729 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user