Compose Elm apps with typed message passing
Objective | โ Flat TEA | โ Nested TEA | ๐ผ elm-composer |
---|---|---|---|
Component model is independent of app model | โ | โ app model contains component model | โ |
Component msg is independent of app msg | โ | โ component msg is wrapped in app msg | โ |
Component handles its own initialisation | โ | โ app calls component's init | โ |
Component handles its own updates | โ | โ app's update calls component's update | โ |
Component handles its own subscriptions | โ | โ app's subscriptions calls component's subscriptions | โ |
Component generates its own view | โ | โ app's view calls component's view | โ (optional) |
Pure Elm (no ports, no JS, no codegen) | โ | โ | โ |
No functions in Msg or Model | โ | โ | โ |
Framework agnostic (e.g. works with elm-ui) | โ | โ | โ |
Easy types | โ | ๐ค | ๐ |
Clear Errors | โ | ๐ค | ๐คฎ |
import Composer.Element as CE
import Browser
main =
CE.integrate myApp
|> CE.withSandbox counter
|> CE.withElement clock
|> CE.withSimpleComponent stopwatch
|> CE.withComponent timer (\toApp appModel -> { timerExpired = TimerExpired } )
|> CE.groupedAs
(\counter_ clock_ stopwatch_ timer_ ->
{ counter = counter_
, clock = clock_
, stopwatch = stopwatch_
, timer = timer_
}
)
|> Browser.element
myApp
is our main application, which we will be integrating various components into. It's going to be a Browser.element
application, which is why we've imported the Composer.Element
module here.
myApp
should be a record that contains the same four fields that we would usually pass to Browser.element
: init
, update
, view
and subscriptions
. But there are a couple of things we'll need to change.
- First, we need to add two extra arguments to
myApp
'sinit
,update
,view
andsubscriptions
functions. Let's call those argumentscomponents
andtoSelf
. For example:- init flags = ... + init components toSelf flags = ... - update msg model = ... + update components toSelf msg model = ... - view model = ... + view components toSelf model = ... - subscriptions model = ... + subscriptions components toSelf model = ...
- Second, if we want
myApp
to be able to send itself anyMsg
s, we need to wrap thoseMsg
s intoSelf
. For example, in ourinit
andupdate
functions:In our- Task.perform (\now -> TimeUpdated now) Time.now + Task.perform (\now -> toSelf (TimeUpdated now)) Time.now
view
function:And in our- Html.Events.onClick Increment + Html.Events.onClick (toSelf Increment)
subscriptions
function:- Time.every 1000 (\now -> TimeUpdated now) + Time.every 1000 (\now -> toSelf (TimeUpdated now))`
Patience, friend! First, let's put together the simplest possible example of an app with an integrated component.
Make a file called Main.elm
, and add the following code::
-- in Main.elm
module Main exposing (main)
import Html
myApp =
{ init =
\components toSelf flags ->
((), Cmd.none)
, update =
\components toSelf msg model ->
((), Cmd.none)
, view =
\components toSelf model ->
Html.div []
[ Html.text "Hello world" ]
, subscriptions =
\components toSelf model ->
Sub.none
}
Yes, that's right, it's an app that does absolutely nothing except display "Hello world" in the browser.
We're going to integrate the Counter
example from the Elm Guide into this app. Imagine we've copy-pasted the code from the Elm Guide into a file called Counter.elm
, and added a module declaration at the top like this:
-- in Counter.elm
module Counter exposing (init, update, view)
Switch back to our Main.elm
file, and add this:
-- in Main.elm
import Counter
counter =
{ init = Counter.init
, update = Counter.update
, view = Counter.view
}
Now, let's use elm-composer
to write our main
function:
-- in Main.elm
import Browser
import Composer.Element as CE
main =
CE.integrate myApp
|> CE.withSandbox counter
|> CE.groupedAs
(\counterView ->
{ counterView = counterView
}
)
|> Browser.element
We can run this in elm reactor
or (even better) elm-watch
, and we should see... hmmm... just "Hello world" in our browser. What happened to our counter?
Well, we need to do one more bit of wiring to make this work. Let's revisit myApp
's view
function:
-- in Main.elm
myApp =
{ ...
, view =
\components toSelf model ->
Html.div []
- [ Html.text "Hello world" ]
+ [ Html.text "Here's your counter!"
+ , components.counterView
+ ]
Ta-daaa! We should see the Elm Guide's counter in all its glory!
In our main
function, we called a function called groupedAs
. The groupedAs
function takes the output of our counter component's view
function (i.e. a value of type Html Counter.Msg
), converts its msg
type to a type that is compatible with elm-composer
, and puts it into a record, under a field called counterView
.
At runtime, elm-composer
passes this record into myApp
s init
, update
, view
and subscriptions
functions as the first argument, which we've called components
.
So, in myApp
's view
function, if we call components.counterView
, we'll display the output of the counter's view
function.
Let's recap: we now have a component, counter
, which is effectively a Browser.sandbox
program, and it's embedded in our main application, myApp
.
A sandbox
is great if we just want a component that manages a little bit of state and provides a little bit of interactivity via HTML events.
But what if we want to do something a bit more powerful - for example, what if we want a component that can send messages via Cmd
from its init
and update
functions and receive messages via Sub
in its subscriptions
?
Well, in a very similar analogy to elm-browser
, we can create a component that is the equivalent of a Browser.element
, using the withElement
function.
Let's integrate the Clock
example from the Elm Guide into this app. Copy-paste the code from the Elm Guide into a file called Clock.elm
, and add a module declaration at the top like this:
-- in Clock.elm
module Clock exposing (init, update, view, subscriptions)
And then add a new component definition in Main.elm
:
-- in Main.elm
import Clock
clock =
{ init = Clock.init
, update = Clock.update
, view = Clock.view
, subscriptions = Clock.subscriptions
}
Then add it to our main
function:
-- in Main.elm
main =
CE.integrate myApp
|> CE.withSandbox counter
+ |> CE.withElement clock
|> CE.groupedAs
- (\counterView ->
+ (\counterView clockView->
{ counterView = counterView
+ , clockView = clockView
}
)
|> Browser.element
And update our view
:
-- in Main.elm
myApp =
{ ...
, view =
\components toSelf model ->
Html.div []
[ Html.text "Here's your counter!"
, components.counterView
+ , Html.text "And here's your clock!"
+ , components.clockView
]
Why did we rename the component's view
function to interface
? I'm so glad you asked!
In the first component we write, we will probably do something like this:
type alias CounterModel =
{ count : Int
, ... other fields
}
type CounterMsg
= Increment
| ... other variants
counter =
{ init = ...
, update = ...
, subscriptions = ...
, interface =
\toSelf model ->
Html.button
[ Html.Events.onClick (toSelf Increment) ]
[ Html.text (String.fromInt model.count) ]
}
Our counter
component's interface
function simply returns a value of type Html msg
.
Look back at the function that we passed to Composer.Element.compose
in our main
function:
|> Composer.Element.compose (\counter_ clock_ -> { counter = counter_, clock = clock_ })
This function takes the return value of each component's interface
function and inserts it into a record. The counter
component's interface is in the counter
field of the record, and the clock
component's interface is in the clock
field.
This record is then passed into our main app's init
, update
, view
, and subscriptions
functions as their first argument. Let's call that argument "components".
Now, if we want to render the HTML returned from our counter
component's interface
, all we need to do is add components.counter
somewhere in our main app's view function, like so:
myApp =
{ init = ...
, update = ...
, subscriptions = ...
, view =
\components toSelf model ->
Html.div []
[ Html.text "Behold my wondrous counter component!"
, components.counter
]
}
But! Unlike a normal Elm app's view
function, there is no need for our interface
function to return Html msg
. It can return any type we like.
So, maybe instead of having the component render itself and forcing our main app to live with those rendering decisions, we could instead provide the ingredients the main app will need to render the component properly:
counter =
{ init = ...
, update = ...
, subscriptions = ...
, interface =
\toSelf model ->
{ count = model.count
, increment = toSelf Increment
}
}
This isn't simply a view, it's an interface - a way to control how the main app is allowed to interact with our component. And we use it thus:
myApp =
{ init = ...
, update = ...
, subscriptions = ...
, view =
\components toSelf model ->
Html.div []
[ Html.text "Behold my wondrous counter component which I have rendered myself!"
, Html.button
[ Html.Events.onClick components.counter.increment ]
[ Html.text (String.fromInt components.counter.count) ]
]
}
The neat thing about this is that it lets us enforce as much or as little encapsulation of our components as we please.
- The main app cannot directly see what is in the component's model, unless our
interface
function returns that model. - The main app cannot send messages to the component, unless our
interface
function returns the component'stoSelf
message constructor, or provides specific message variants for the main app to send.
My friend, we are programmers - we can always make things more complicated.
So far, we've got a component that can provide an interface that controls how our main app can interact with it.
But what if we also want the converse: an interface that controls how our component can interact with our main app's model, and specifies what messages it can send to the main app?
This is where Composer.component
comes in.
Under the covers, when we call Composer.componentSimple
, what's really happening is this:
import Composer.Element as Composer
import Browser
main =
Composer.app app
- |> Composer.componentSimple counter
+ |> Composer.component
+ counter
+ (\toApp appModel -> ())
|> Composer.Element.componentSimple clock
|> Composer.Element.compose (\counter_ clock_ -> { counter = counter_, clock = clock_ })
|> Browser.element
As you can see, there's now an extra function passed to each component that specifies the interface that the main app provides to the component. In this case, we simply return the unit type ()
, and that ()
gets passed to our component's update
, view
and subscriptions
functions as the app
argument.
Now, just as with the interface
function we defined earlier in our component, this new app interface function can return any type we like. Instead of returning ()
, we might decide expose a limited subset of the variants of the main app's msg
type, or a subset of fields from the main app's model
.
To see how this might work in practice, check out the DnD
example in the examples folder.