Elm patterns
A collection of common patterns for Elm.
Type blindness
Type blindness is when you have several values of the same type that could get mixed up.
Example 1
type alias User =
{ firstName: String, lastName: String }
Both attributes are String
. It is easy to mix them up when receiving external information.
Example 2
priceInDollars : Float
priceInDollars = 2.0
priceInEuros : Float
priceInEuros = 1
Both values are Float
. There is nothing preventing us from doing a non sensical operation like priceInDollars + priceInEuros
.
Pattern
In this case consider wrapping values in a unique type.
type Dollar = Dollar Float
priceInDollars : Dollar
priceInDollar =
Dollar 2.0
You will need to unwrap values later, which can get tedious, so use this when there is a good potential of mixing up values.
Minimize boolean usage
Boolean ambiguity can lead to loss of intent and loss of information in our code, resulting in ambiguous logic. To avoid this issue, we should prefer self-documenting alternatives whenever the intention is unclear.
This pattern is inspired by Jeremy Fairbank's "Solving the Boolean Identity Crisis" talk and series of articles, where he explains the rationale and the pattern in great details, if you want to delve deeper.
Boolean Ambiguity
Boolean arguments can make code confusing and harder to maintain by hiding the intent of code.
Anti-Pattern 1
What is the significance of the True
value here without looking up the definition of bookFlight
?
bookFlight "ELM" True
Pattern 1
We can clean up the bookFlight
function by replacing the boolean arguments with a Custom Type
:
type CustomerStatus
= Premium
| Regular
| Economy
Now calls to bookFlight
declare the intent of code because we pass in the CustomerStatus
directly:
bookFlight "ELM" Premium
Boolean Blindness
Returning Bool
from a function can lead to boolean blindness. This happens because we get a value that the type system cannot use for enforcing further logic in the program.
Anti-Pattern 2
For example:
time =
if isValid formData then
submitForm formData
else
showErrors formData
In this snippet isValid
only returns a Bool
. We can call submitForm
with the original formData
. The compiler wouldn't complain if we swap the if-else branches.
Pattern 2
Replace boolean return values with custom types to eliminate boolean blindness and leverage the compiler for safer code. In this example we do it with a Result Error Type
:
time =
case isValid formData of
Ok validFormData ->
submitForm validFormData
Err errors ->
showErrors errors
In this case submitForm
is a function that can only be called with valid form data.
Named arguments
There are times when the order of arguments for a functions can be ambiguous. For example:
isBefore : Date -> Date -> Bool
What is the subject date here and the comparison date?
As we usually put the data at the end (for pipelines) we might think that the subject is the second date, but maybe is not. This is vague and error prone.
In this cases is better if we ask for a record as argument:
isBefore : { subject: Date, comparedTo: Date } -> Bool
This might not be pipeline friendly, but it is a lot more precise and hard to get wrong.
Wrap early, unwrap late
To avoid type blindness, you probably want to wrap your values in unique types.
When you do this it is a good idea to wrap your types as early as possible. E.g. when decoding values from external sources.
Then try to unwrap as late as possible.
Example anti-patterns
displayPriceInDollars : Float -> String
displayPriceInDollars price =
"USD$" ++ String.fromFloat price
There is nothing stopping us from passing something that is not dollars here (e.g. we might use Euros).
calculateTotalPrice: List Float -> Float
We could send a list of mixed currencies e.g. dollars and euros.
Pattern
type Dollar = Dollar Float
displayPriceInDollars : Dollar -> String
displayPriceInDollars (Dollar price) =
"USD$" ++ String.fromFloat price
This enforces that we use the Dollar
type as much as possible.
Unwrap Maybe and Result early
If you have a Maybe
, Result
or RemoteData
it is often a good idea to try to unwrap those as early as possible.
Anti-pattern
userCard : Maybe User -> Html Msg
userCard maybeUser =
div []
[ userInfo maybeUser
, userActivity maybeUser
]
In here both userInfo
and userActivy
get a Maybe User
. Meaning that will need to unwrap this value several times in your views.
Pattern
userCard maybeUser =
case maybeUser of
Nothing ->
div [] [ ... ]
Just user ->
div []
[ userInfo user
, userActivity user
]
Here the sub views take a User
, this makes most of your views easier to write and test.
Make impossible states impossible
Elm has a great and expressible type system. This type system allows us to avoid having impossible states in our application.
Anti-pattern
A common pattern is to have a boolean attribute to show a loading spinner while data is loading. e.g.
type alias Model =
{ isLoading: Bool
, data: Maybe Data
}
But in this type it is possible to have something like isLoading = false
and data = Nothing
. What is the meaning of this? This is probably an impossible state that should never happen.
Pattern
With Elm you can represent your types in ways that don't allow for impossible states. e.g
type RemoteData
= Loading
| Loaded Data
type alias Model =
{ data : RemoteData }
Here is an excellent talk about this.
Parse don't validate
When we have external data (e.g. user input or remote data) it is a common pattern to validate this data before using it.
Anti-pattern
A common approach is to ask if the data is valid and then use it e.g.
type alias UserInput =
{ name: Maybe String
, age: Maybe Int
}
isValidUser : UserInput -> Bool
The problem with this approach is that after doing this you still have a UserInput
with can still hold invalid values.
Pattern
A better approach is to "parse" your input and return a known valid type. e.g.
type alias UserInput =
{ name: Maybe String
, age: Maybe Int
}
type alias ValidUser =
{ name: String
, age: Int
}
validateUser : UserInput -> Result String ValidUser
In this way you ensure that you have a valid type to work with later on. Some examples where this is useful:
- Validating user input
- Parsing JSON from external sources
The builder pattern
When we need to pass many arguments to a function we might have something like
module Button exposing (..)
type alias Args =
{ isEnabled : Bool
, label : String
, hexColor : String
, ...
}
btn: Args -> Html msg
In the caller module:
import Button
Button.btn { isEnabled = True, label = "Click me", ....}
The problem with this is that each time we add an argument to Args
we need to change every single place where we call this function.
Pattern
With builder pattern we build the arguments with the minimum necessary information, then modify the arguments if we need to.
module Button exposing (..)
newArgs: String -> Args
newArgs label =
{ isEnabled = True
, label = label
, hexColor: "#ABC"
, ...
}
withIsEnabled : Bool -> Args -> Args
withIsEnabled isEnabled args =
{ args | isEnabled = isEnabled }
btn: Args -> Html msg
This modules exposes a function to create the initial arguments and a series of function to modify the arguments (commonly using with
as prefix).
Then the caller module uses this:
import Button
aButton =
Button.newArgs "Click me"
|> Button.withIsEnabled False
|> Button.withHexColor "#123"
|> Button.btn
The advantage of this is that adding new arguments to Args
doesn't require us to change every caller.
As test factories
This pattern is also very useful for tests. Similar to test factories in many languages. For example if we were testing a User
, we could start with a basic user and then use the builder pattern to modify attributes for different tests.
Arguments list
The builder patterns gives us to build configuration. We could also use plain lists for this:
view =
shape
[ scale 0.5 0.5 0.5
, position 0 -6 -13
, rotation -90 0 0
]
Our module exposes a series of functions that return a common type e.g.
scale : Float -> Float -> Float -> Attribute
rotation : Int -> Int -> Int -> Attribute
Using that we can build a List Attribute
and pass it to a function as configuration.
This is the pattern used in elm/html, elm/svg, elm-css, elm-ui.
- This pattern is best used with Opaque types. As we don't usually want the caller to be able access the returned type (e.g.
Attribute
). - This pattern is best when all arguments are optional as we cannot avoid having the caller pass an empty list.
Type iterator
When we have a list of all the variants in our custom type, sometimes that list can get out-of-sync when we add a new variant.
module Color exposing (Color(..), all)
type Color
= Red
| Yellow
| Green
| Blue -- Recently added
all : List Color
all =
[ Red, Yellow, Green ]
-- Forgot to add Blue here
It would be nice if the compiler could remind us. Especially so if there are many variants.
There are at least two solutions to this.
The first one is using an elm-review
rule NoMissingTypeConstructor
that automatically checks lists of custom type constructors named all*
and warns when they get out of sync.
The second one is to get the compiler to remind us of a missing variant by building our list with a case
statement.
module Color exposing (Color(..), all)
type Color
= Red
| Yellow
| Green
| Blue -- Recently added
all : List Color
all =
next [] |> List.reverse
next : List Color -> List Color
next list =
case List.head list of
Nothing ->
Red :: list |> next
Just Red ->
Yellow :: list |> next
Just Yellow ->
Green :: list |> next
Just Green ->
list -- return the list as is on the final variant
-- Blue is still missing, but now, the compiler will complain
-- until we add it here
Important: We should never use wildcard matching _
in the next
function.
It would prevent the compiler from detecting new variants.
Conditional rendering
Many time we want to conditionally render an element. For example, sometimes we want to render a banner, sometimes we don't. Here are some common patterns for this:
Using Maybe
We can write our HTML like:
[ Just headerElement
, maybeBanner showBanner
, Just content
, Just footerElement
] |> List.filterMap identity
maybeBanner showBanner =
if showBanner then
Just bannerElement
else
Nothing
In this pattern we create a list of Maybes, and then use List.filterMap identity
to remove the Nothings.
Using a no-op
We can also achieve this using:
[ headerElement
, maybeBanner showBanner
, content
, footerElement
]
maybeBanner showBanner =
if showBanner then
bannerElement
else
text ""
In this case text ""
is a no-op (No operation). So nothing gets rendered.
To achieve the same for attributes we can use class ""
.
Using list concatenation
And we could also write our HTML like:
( [ headerElement ]
++ maybeBanner showBanner
++ [ content, footerElement ]
)
maybeBanner showBanner =
if showBanner then
[ bannerElement ]
else
[]
In this case we use list concatenation to assemble the HTML. When we don't need to render the banner we can return an empty list.
The railway pattern
The railway pattern is a way of chaining operations where each might fail. It is called railway because there are two tracks in this pattern.
- The first track is the happy path
- The second track is the error track
If any of the chained functions fails we move to the error track. From there we get an error at the end of the railway.
For example, let say we want to:
- Parse some external data
- Validate the parsed data
- Transform the data into something else
parseData : String -> Result String ParsedData
validateData : ParsedData -> Result String ValidData
transformData : ValidData -> Result String TransformedData
Pattern
In Elm this is commonly done using Maybe.andThen
and Result.andThen
. These function will run the next function in the chain if the previous function was successful, otherwise they will propagate the error.
process : String -> Result String TransformedData
process data =
parseData data
|> Result.andThen validateData
|> Result.andThen transformData
Here is an excellent post about this with a lot more details.
Variant
A variant of this is where the second track doesn't represent an error, but rather an early exit.
E.g. This process finds recommendations for a user. Each function in the chain can add to the recommendations or choose to exit the process.
type Process
= Continue Recommendation
| Exit Recommendation
andThen : (Recommendation -> Process) -> Process -> Process
andThen callback process =
case process of
Continue document -> callback document
Exit document -> Exit document
findRecommendations user =
Continue emptyRecommendation
|> andThen (findMusic user)
|> andThen (findBooks user)
|> andThen (findMovies user)
|> andThen (findGames user)
findMusic : User -> Recommendation -> Process
...
andThen
is a function that mirrors Result.andThen
but specific for Process
.
Pipeline builder
This is a common pattern used for decoders and validation. This pattern is used to build a function for processing some data using a series of piped functions.
type alias User =
{ name: String
, age: Int
}
validateUser : User -> Result String User
validateUser user =
Ok User
|> validateName user.name
|> validateAge user.age
This builds a function validateUser
that will take a user and validate it. This validateUser
function works like the railway pattern. We might get an Ok User
at the end or an error Err String
.
This pattern relies on the fact that a type alias in Elm can be used as a function. e.g. User
is a function like:
String -> Int -> User
We start by putting the function (User
) into a Result.
Then each function in the chain takes an attribute and the previous result, does the validation and returns a result back.
validateName : String -> Result String (String -> a) -> Result String a
validateName name =
Result.andThen
(\constructor ->
if String.isEmpty name then
Err "Invalid name"
else
Ok (constructor name)
)
Complete example https://ellie-app.com/9SZTHJqB5r2a1
Caveat
When using this pattern we have to be careful with the order of functions in the pipeline. It is easy to make a mistake when the end type has many attribute of the same type.
type alias User =
{ name: String
, email: String
}
With this type, we can mix up the order of name validation and email validation e.g.
Ok User
|> validateEmail user.email
|> validateName user.name
This will work, but it will give us a User
with the attributes mixed up:
{ name = "sam@sample.com"
, email = "Sam"
}
Some example packages using this:
- Json decoding https://package.elm-lang.org/packages/elm/json/latest/Json.Decode
- Validation https://package.elm-lang.org/packages/stoeffel/elm-verify/latest/
- More validation https://package.elm-lang.org/packages/gege251/elm-validator-pipeline/
Opaque types
Opaque types are types that cannot be created outside of a specific module. For example:
module Lib exposing (Config)
type Config = Config { size: Int, style: Style }
This module expose the Config
type but not the constructor. An external module can't construct or modify a Config
type.
Opaque types are useful for:
- Enforcing invariants: Only the relevant module can change the data and ensure it follows some invariants.
- Hiding the implementation to external modules. This is quite useful for building packages. Using opaque types makes it easier to change the implementation without breaking the code using it.
Anti-pattern
module Lib exposing (Config)
type alias Config = { size: Int, style: Style }
This module exposes Config
transparently. Any changes we want to make will require changes in the module using this. In the case of a package, if we remove or change a type in Config
we will have to publish a major version of this package.
Pattern
module Lib exposing (Config, newConfig, withSize)
type Config = Config { size: Int, style: Style }
newConfig : Config
newConfig =
Config { size: 1, style : Big }
withSize : Int -> Config -> Config
This module allows an application to create a Config
and update it. But if we decide to change how we store the Config
we can do so without any breaking changes for the caller module using this.
The next page explains using opaque types for enforcing invariants.
Opaque types for enforcing invariants
Some times we want our data to always follow certain rules. E.g. We would like a list that is always sorted.
Using opaque types we can create a module that enforces this invariant.
module SortedList exposing (SortedList, new, add)
type SortedList comparable =
SortedList (List comparable)
new : SortedList comparable
new =
SortedList []
add : comparable -> SortedList comparable -> SortedList comparable
Only this module can create a SortedList
as we don't expose the constructor.
Also, only this module can add an item to the list. By doing this we can enforce that the list is always sorted. External modules cannot change this data, so they are unable to break the sort invariant.
Combinators
Combinators is a technique where combining several values of the same type gives us back a value of the same type.
For example:
and : Filter -> Filter -> Filter
and
is a function that takes two filters and combines them using an AND
join. It gives us back another Filter
.
Given that the return value is the same type, we can keep combining them endlessly.
Some examples of combinators are:
- Html
- Cmd.batch
- Parsers
- JSON Decoder / Encoders
- Filters e.g. (a AND (b OR c))
- Validations
Anything that resembles a tree is a good candiate for using combinators.
Combinators allow us to:
- Easily test each small part in isolation
- Make complex systems from very small part
- Create different combinations by cherry picking the parts we need
Phantom types
Phantom types are types that have a type variable on the type that is not used constructors. E.g.
type Users a =
Users (List User)
This type variable allows us to restrict what type a function can take and return. For example:
type Active
= Active
activeUsers : List User -> Users Active
activeUsers users =
users
|> List.filter isActive
|> Users
activeUsers
is a function that takes all users and only returns active users.
Phantom types are useful for things like:
- Enforcing invariants in functions and views
- Validation
- State machines
- Processes
In the example below we could have a view that only takes active users:
usersView : Users Active -> Html msg
The compiler will complain if we try to pass all users to this view. In this way it can be sure that we are filtering users correctly.
Process flow using phantom types
Sometimes we want to create a process that needs to follow different paths until it reaches an end point. Like a finite state machine.
For example we want an order form where you can change the total or the quantity. When you change any of these two, the other value needs to change automatically.
- When the user updates the total we want to update the quantity
- When the user updates the quantity we want to update the total
These two flows can be illustrated with a state machine:
This is a simple example to illustrate this pattern. This particular example can be done with less ceremony, but in more complex scenarios this pattern is really valuable.
We want to design our code in way that:
- Enforces running through the process steps in the correct order (depending on the user intention)
- Doesn't let us forget a step
Anti-pattern
One possible way of doing this is by creating intermediate types for our process:
type alias InvalidOrder =
{ quantity : Maybe Int, total : Maybe Int }
type alias OrderWithQuantity =
{ quantity : Int, total: Maybe Int }
type alias OrderWithTotal =
{ quantity : Maybe Int, total: Int }
type alias Order =
{ quantity : Int, total : Int }
setTotal : Int -> InvalidOrder -> OrderWithTotal
adjustQuantityFromTotal : OrderWithTotal -> Order
setQuantity : Int -> InvalidOrder -> OrderWithQuantity
adjustTotalFromQuantity : OrderWithQuantity -> Order
Although this is not too bad here, if we were to have more paths and attributes the multiplication of intermediate types would get out of hand really quickly.
Pattern
Phantom types allow us to deal with this in an elegant way.
State machine states
First we need some types to define the state machine states:
type Step step
= Step Order
type Start
= Start
type OrderWithTotal
= OrderWithTotal
type OrderWithQuantity
= OrderWithQuantity
type Done
= Done
The phantom type
here is in Step
. Note how it defines a step
type variable that is not used in the constructor.
Transitions
Then we can create transition functions that use the phantom type to restrict what they take and return.
For example:
adjustQuantityFromTotal : Step OrderWithTotal -> Step Done
This function can only take a Step
that is in the OrderWithTotal
state. And returns a Step
in Done
state.
For our order state machine we need these functions:
start : Order -> Step Start
setTotal : Int -> Step Start -> Step OrderWithTotal
adjustQuantityFromTotal : Step OrderWithTotal -> Step Done
setQuantity : Int -> Step Start -> Step OrderWithQuantity
adjustTotalFromQuantity : Step OrderWithQuantity -> Step Done
done : Step Done -> Order
These functions are state machine transitions. They only allow moving from specific states to other specific states.
Flows
And finally we can build the valid flows using these functions:
flowPrioritizingTotal total order =
start order
|> setTotal total
|> adjustQuantityFromTotal
|> done
flowPrioritizingQuantity quantity order =
start order
|> setQuantity quantity
|> adjustTotalFromQuantity
|> done
In this way we can enforce specific processes without creating a myriad of intermediate types.
See a working example here https://ellie-app.com/smDDnCh5C8Xa1
Reusable views
It is common to need reusable views in our applications. The most basic way in Elm is to have view functions that take message constructors as arguments.
For example:
type alias Args msg =
{ currentDate: Date
, isOpen: Bool
, onOpen: msg
, onClose: msg
, onSelectDate : Date -> msg
}
calendar : Args msg -> Html msg
calendar args =
This view is easy to integrate and reuse. However it requires the caller to keep track of the state e.g. isOpen
.
Another limitation is that this view is incapable of producing commands.
Using the builder pattern
Reusable views like these are perfect candidates for the builder pattern:
Button.newArgs "Clear selection" Clear
|> Button.withIcon IconClear
|> Button.withSize Button.Wide
|> Button.view
The nested Elm architecture
When an application starts growing large we might want to break the application messages into discrete parts. For example:
- Root Application
- Page 1
- Page 2
- ...
Pattern
The nested Elm architecture is a way of achieving this.
module Main exposing (main)
import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)
import Sub
type alias Model =
{ count : Int
, subModel : Sub.Model
}
newModel : Model
newModel =
{ count = 0
, subModel = Sub.newModel
}
type Msg
= Increment
| Sub Sub.Msg
update : Msg -> Model -> Model
update msg model =
case msg of
Increment ->
{ model | count = model.count + 1 }
Sub subMessage ->
{ model
| subModel =
Sub.update subMessage model.subModel
}
view : Model -> Html Msg
view model =
div []
[ div [] [ text <| String.fromInt model.count ]
, button [ onClick Increment ] [ text "+1" ]
, Sub.view model.subModel |> Html.map Sub
]
main : Program () Model Msg
main =
Browser.sandbox
{ init = newModel
, view = view
, update = update
}
Note the Sub.view model.subModel |> Html.map Sub
in view
.
Sub.elm :
module Sub exposing (..)
...
type alias Model =
{ count : Int }
newModel : Model
newModel =
{ count = 0
}
type Msg
= Increment
update : Msg -> Model -> Model
update msg model =
case msg of
Increment ->
{ model | count = model.count + 1 }
view : Model -> Html Msg
view model =
div []
[ div [] [ text <| String.fromInt model.count ]
, button [ onClick Increment ] [ text "+1" ]
]
This pattern comes with its own set of challenges like:
- Added boilerplate
- It is not simple for the child module to communicate with the parent module (see Translator).
So this pattern is best used sparingly.
Child Outcome
As described in Nested TEA it is sometimes useful to create a nested Elm architecture. In that case we might want to send a message from the child to the parent after an action.
One simple way to do this is by returning a third value in the update
function of the child.
module Child exposing
( Outcome(..)
, update
, ...
)
type Outcome
= OutcomeNone
| OutcomeDateUpdated Date
update : Msg -> Model -> (Model, Cmd Msg, Outcome)
Then the parent module can deal with that:
module Parent exposing (...)
import Child
...
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ChildMsg childMsg ->
let
(nextChildModel, childCmd, childOutcome) =
Child.update childMsg model.childModel
-- Do something with the childOutcome
...
Translator
As described in Nested TEA it is sometimes useful to create a nested Elm architecture. When making this we normally would use Html.map
to route messages back to the nested module.
For example, in the parent container you would have:
module Parent exposing(..)
import Child
Child.view model.childModel |> Html.map ChildMsg
The challenge of using Html.map
here is that messages produced in the child module always need to route back to itself. We cannot easily produce a message in the child destined to its parent.
As an application grows it is common to encounter something like this, perhaps we need a UI element in the child module that needs to send the message to its parent.
Pattern
The solution to this is to make the child module views generic and provide a constructor for routing messages. So, instead of View Msg
in the child module, it becomes View msg
. Then the parent explicitly provides the constructor to route messages.
For example, we have a child module with a view like:
module Child exposing(..)
view: Model -> Html Msg
view model =
div []
[ button [ onClick Clicked ] [ text model.name ]
]
We want to add another button, but this time it should send a message to its parent.
First, make the child module generic
module Child exposing(..)
view: Model -> (Msg -> msg) -> Html msg
view model toSelf =
div []
[ button [ onClick (toSelf Clicked) ] [ text model.name ]
]
To toSelf
is a constructor that wraps the internal message, producing a parent message. This replaces Html.map
in the parent module and will route the message back to this module.
In the parent container we would use this view like:
module Parent exposing(..)
import Child
type Msg = ChildMsg Child.Msg
view model =
...
Child.view model.childModel ChildMsg
Produce a parent message
With this setup we can produce parent messages from the child module.
module Child exposing(..)
type alias Args msg =
{ toSelf : Msg -> msg
, onSave: msg
}
view: Model -> Args msg -> Html msg
view model args =
div []
[ button [ onClick (args.toSelf Clicked) ] [ "Send to self" ]
, button [ onClick args.onSave ] [ text "Send to parent" ]
]
The parent would call this like:
module Parent exposing(..)
import Child
type Msg
= OnSave
| ChildMsg Child.Msg
view model =
...
Child.view
model.childModel
{ toSelf = ChildMsg
, onSave = OnSave
}
Global actions
If we use the nested TEA for our application, we will most likely need a way for the nested modules to communicate with the top levels. For example:
- Open a notification
- Sign out the user
- Return a value to the top level
There are many way of achieving this. One possible way is to have a module with global actions e.g.
module Actions exposing (..)
type Action
= OpenSuccessNotification ...
| OpenFailureNotifiation ...
| ...
The all your nested module will return three elements on update
. The third one being a list of actions to execute:
update : Msg -> Model -> (Model, Cmd Msg, List Action)
Any nested module in the chain could add an action to the list.
Finally your root update
will need to map through the list and process the actions.
- A nice pattern for adding actions is to mimic
Cmd.batch
. E.g.Actions.batch
. - We might need to send a message back to the module that returned the action. E.g. Open a dialog with selections. In this case our actions might need a message associated with them e.g.
Action Msg
. We will need aActions.map
to just likeHtml.map
.
The effects pattern
In a usual Elm application the update
function returns (Model, Cmd Msg)
. Cmd
in Elm is an opaque type so update
functions are not easy to test. We cannot easily inspect the commands and see if they are doing the right thing. We cannot also simulate these commands as we don't know what they are doing.
Pattern
The effects pattern allows us to deal with these issues by returning an Effect
type instead of Cmd msg
from update
.
type Effect
= SaveUser User
| LogoutUser
| LoadData
| ...
update : Msg -> Model -> (Model, List Effect)
At the last possible moment we convert the Effect
into actual commands. E.g. in the root module of the app we would have a function like:
runEffects : List Effect -> Cmd Msg
This makes a Elm application a lot more testable. This is approach taken by elm-program-test.
Here is a more detailed blog post about this pattern.
Update return pipeline
Sometimes in the our update function we need to do many different things. For example:
- Change the state of the model
- Change some value in the browser query
- Conditionally load more data
- Do some analytics tracking
We can do all these things at once:
case msg of
SeeReport report ->
let
nextModel =
{ model
| stage = ReportVisible report
, loading =
if needsToLoadMoreData model then
Loading
else
model.loading
}
cmd =
Cmd.batch
[
if needsToLoadMoreData model then
loadMoreDataCmd
else
Cmd.none
, setSomeValueInUrl
, TrackEvent.track {... }
]
in
(nextModel, cmd)
... ->
But in these cases our update branches can get very complex very quickly. Making them difficult to understand and ripe for bugs.
Pattern
A nice way to make many things in an update branch is to break them by concerns and create a "return" pipeline.
case msg of
SeeReport report ->
(model, Cmd.none)
|> andThen (setStageToReportVisible report)
|> andThen loadMoreDataIfNeeded
|> andThen addKeyInUrl
|> andThen trackSeeReportEvent
In this case andThen
is a function like:
andThen : (model -> (model, Cmd msg)) -> (model, Cmd msg) -> (model, Cmd msg)
andThen fn ( model, cmd ) =
let
( nextModel, nextCmd ) =
fn model
in
( nextModel, Cmd.batch [ cmd, nextCmd ] )
This function takes another function that given the model
returns a (model, Cmd msg)
just like update
.
andThen
takes care of batching commands together.
Every function is the pipeline will be responsible for only one thing, which is a lot easier to understand. E.g.
loadMoreDataIfNeeded : Model -> (Model, Cmd Msg)
loadMoreDataIfNeeded model =
if needsToLoadMoreData model then
({ model | loading = Loading }, loadMoreDataCmd)
else
(model, Cmd.none)