Image taken from unsplash, @ashkfor121

The Principles Behind TEA-Combine

Attempting to Programmatically Combine Stateful Components in The Elm Architecture

It’s been a while since I fiddled with Elm. Though I don’t use it regularly I sometimes take a stroll around it to see how it’s doing. This time I stumbled upon tea-combine, a small library made with the purpose of easily glueing together subcomponents of a bigger application.

The Use Case

Let’s take the library’s front page example and try to implement it in vanilla Elm, without fancy combinations. The result will be a barren web page, with just an interactive counter and two checkboxes.

Simple but effective
  • an update functions that reacts to events and changes the Model accordingly
  • a view function that renders our data as html on the web page
type alias Model =
{ checkboxState1 : Bool, checkboxState2 : Bool, counterState : Int }
type Msg
= CheckboxToggle1
| CheckboxToggle2
| CounterNumber Int
update : Msg -> Model -> Model
update msg model =
case msg of
CheckboxToggle1 ->
{ model | checkboxState1 = not model.checkboxState1 }
CheckboxToggle2 ->
{ model | checkboxState2 = not model.checkboxState2 }
CounterNumber counter ->
{ model | counterState = counter }
view : Model -> Html Msg
view model =
Html.div []
[ Html.span
[]
[ Html.button [ onClick <| CounterNumber <| model.counterState - 1 ] [ Html.text "-" ]
, Html.text <|
Debug.toString model.counterState
, Html.button [ onClick <| CounterNumber <| model.counterState + 1 ] [ Html.text "+" ]
]
, Html.input
[ checked model.checkboxState1
, type_ "checkbox"
, onClick CheckboxToggle1
]
[]
, Html.input
[ checked model.checkboxState2
, type_ "checkbox"
, onClick CheckboxToggle2
]
[]
]
module Main exposing (main)import Browser
import Html exposing (Html)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
main =
Browser.sandbox
{ init =
{ checkboxState1 = False, checkboxState2 = False, counterState = 0 }
, view = view
, update = update
}

-- MODEL
type alias Model =
{ checkboxState1 : Bool, checkboxState2 : Bool, counterState : Int }
-- UPDATEtype Msg
= CheckboxToggle1
| CheckboxToggle2
| CounterNumber Int
update : Msg -> Model -> Model
update msg model =
case msg of
CheckboxToggle1 ->
{ model | checkboxState1 = not model.checkboxState1 }
CheckboxToggle2 ->
{ model | checkboxState2 = not model.checkboxState2 }
CounterNumber counter ->
{ model | counterState = counter }

-- VIEW
view : Model -> Html Msg
view model =
Html.div []
[ Html.span
[]
[ Html.button [ onClick <| CounterNumber <| model.counterState - 1 ] [ Html.text "-" ]
, Html.text <|
Debug.toString model.counterState
, Html.button [ onClick <| CounterNumber <| model.counterState + 1 ] [ Html.text "+" ]
]
, Html.input
[ checked model.checkboxState1
, type_ "checkbox"
, onClick CheckboxToggle1
]
[]
, Html.input
[ checked model.checkboxState2
, type_ "checkbox"
, onClick CheckboxToggle2
]
[]
]

Industrializing the Process

While the end result is not bad, you might notice that it could be perfected. For example, the rendering of the two checkboxes has a lot of repeated code; defining an helper function would make it more readable.

-- MODELtype alias Model = 
{ checkboxState1 : CheckBox.Model, checkboxState2 : CheckBox.Model, counterState : Counter.Model }

-- UPDATE
type Msg =
CheckboxToggle1 CheckBox.Msg
| CheckboxToggle2 CheckBox.Msg
| CounterNumber Counter.Model

update : Msg -> Model -> Model
update msg model =
case msg of
CheckboxToggle1 unit ->
{ model | checkboxState1 = CheckBox.update unit model.checkboxState1 }
CheckboxToggle2 unit ->
{ model | checkboxState2 = CheckBox.update unit model.checkboxState2 }
CounterNumber counter ->
{ model | counterState = Counter.update counter model.counterState }

-- VIEW
view : Model -> Html Msg
view model =
Html.div []
[ Counter.view model.counterState
, CheckBox.view model.checkboxState1
, CheckBox.view model.checkboxState2
]
Detected errors in 1 module.                                         
-- TYPE MISMATCH -------------------------------------------------- src/Main.elm
The 2nd element of this list does not match all the previous elements:57| [ Counter.view model.counterState
58|> , CheckBox.view model.checkboxState1
59| , CheckBox.view model.checkboxState2
60| ]
This `view` call produces:Html CheckBox.MsgBut all the previous elements in the list are:Html Counter.MsgHint: Everything in the list needs to be the same type of value. This way you
never run into unexpected values partway through. To mix different types in a
single list, create a "union type" as described in:
<http://guide.elm-lang.org/types/union_types.html>
checkBoxView : CheckBox.Model -> msg -> Html msg
checkBoxView model msg =
Html.input
[ checked model
, type_ "checkbox"
, onClick msg
]
[]

counterView : Counter.Model -> msg -> Html Msg
counterView model msg =
Html.span []
[
Html.button [ onClick <| CounterNumber <| model - 1 ] [ Html.text "-" ]
, Html.text <|Debug.toString model
, Html.button [ onClick <| CounterNumber <| model + 1 ] [ Html.text "+" ]
]
view : Model -> Html Msg
view model =
Html.div []
[ counterView model.counterState <| CounterNumber model.counterState
, checkBoxView model.checkboxState1 <| CheckboxToggle1 ()
, checkBoxView model.checkboxState2 <| CheckboxToggle2 ()
]

Boilerplate Code

No, we are not done yet. Although it is true that we defined two separate and (almost) autonomous components, there should be still something buzzing you.

image courtesy of @scottsanker from unsplash

Lack of Abstraction

This apparently simple problem becomes much harder for a language lacking powerful abstraction tools like Elm. For instance, if we had access to some form of ad hoc polymorphism (that you might know as Typeclasses, Traits, Interfaces or something else depending on your coding background) it would be trivial to define a component as something that is “Updatable” and “Renderable” through self-provided functions. Then, the Model would just contain a list of those components and invoke the provided update and view functions as needed.

Automatically Combine the Components

Let’s see how we can mix together multiple components that expose just their version of a MVU application (a Model type, Msg type and update function, and a view function), just like we did with Counter and CheckBox, but without having to manually write out the glue code. We start with the Model.

Combining the Model

In our previous example, the top Model type was a record with a field for each component. A record is nice to handle manually but not exactly easy to automate, as we are forced to create new fields ourselves.

type alias Model = (Counter.Model, CheckBox.Model, CheckBox.Model)
Detected errors in 1 module.                                         
-- BAD TUPLE ------------------------------------------------------ src/Main.elm
I only accept tuples with two or three items. This has too many:23| type alias Model = (Counter.Model, CheckBox.Model, CheckBox.Model, CheckBox.Model)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
I recommend switching to records. Each item will be named, and you can use the
`point.x` syntax to access them.
Note: Read <https://elm-lang.org/0.19.0/tuples> for more comprehensive advice on
working with large chunks of data in Elm.
type alias Model = (((Counter.Model, CheckBox.Model), CheckBox.Model), CheckBox.Model)
initWith : model2 -> model1 -> Both model1 model2                       initWith m2 m1 = ( m1, m2 )
Counter.init 0
|> initWith (CheckBox.init False)
|> initWith (CheckBox.init False)

Combining the Update

When using with The Elm Architecture we are working with two main custom types: Model and Msg. You might be tempted to combine the Msg types in the same way as we did with the Model, but that’s not quite right.

updateWith : Update model2 msg2 -> Update model1 msg1 -> Update (Both model1 model2) (Either msg1 msg2)                       updateWith u2 u1 =
Either.unpack (Tuple.mapFirst << u1) (Tuple.mapSecond << u2)
\msg -> \(m1, _) -> u1 msg m1
Counter.update
|> updateWith CheckBox.update
|> updateWith CheckBox.update

Composing the View

Adding up view function is just a repetition of the update case, but with a twist. There is a viewBoth function that mashes together two views just like initWith does for Models and updateWith does for updates.

viewBoth : View model1 msg1 -> View model2 msg2 -> Both model1 model2 -> Both (Html (Either msg1 msg2)) (Html (Either msg1 msg2))                       viewBoth v1 v2 ( m1, m2 ) = 
( Html.map Left <| v1 m1, Html.map Right <| v2 m2)
joinViews : View model1 msg1 -> View model2 msg2 -> Both model1 model2 -> List (Html (Either msg1 msg2))                       joinViews v1 v2 m = 
let
( h1, h2 ) = viewBoth v1 v2 m
in
[ h1, h2 ]
withView : View model2 msg2 -> (model1 -> List (Html msg1)) -> Both model1 model2 -> List (Html (Either msg1 msg2))                       withView v2 hs ( m1, m2 ) = 
List.append (List.map (Html.map Left) <| hs m1)
[ Html.map Right <| v2 m2 ]
Html.div []
<< (joinViews Counter.view CheckBox.view
|> withView CheckBox.view
)

Conclusion

Phew! Is it over? For this article yes, but tea-combine goes even further. First of all, we never even considered the possibility of adding subscriptions and commands to our components. Those are handled in a similar fashion by the Effectful module.

Computer Science Master from Alma Mater Studiorum, Bologna; interested in a wide range of topics, from functional programming to embedded systems.