I've been developing a slightly bigger codebase than maybe average with Elm for a while, and I banged my head against some limits StartApp has. But an additional pattern has surfaced which helps a lot, even though it's not officially approved by Evan at the moment.
I'm going to show you what shortcomings of the Elm Architecture it compensates, and how to change your app so you can use it.
At some point I'm going to write about the Elm Architecture and StartApp
more in detail, but meanwhile look at the official docs if perplexed.
This article is part of Road to Elm.
Quick The Elm Architecture (TEA) overview
So an Elm Architecture app is made of:
Actions
which are a custom Union Type- a
Model
, which is usually a Record view : Signal.Address Action -> Model -> Html
update : Action -> Model -> (Model, Effects Action)
.
The update
function is pattern matching on the actions it receives and returns the next state of your model, which you then render.
So basically your program flow is:
- init
- get some input
- transform those inputs in actions
- pattern match the action in update
- return the next state of the model
- render that
and so on.
If you are using StartApp/TEA, and you have subcomponents, say taking the classic TEA example of a list of counters, you need to propagate the actions from the component above, to the subcomponent:
-- from CounterList.elm
module CounterList where
[...]
type Action
= Insert
| Remove ID
| Modify ID Counter.Action
[...]
update action model =
Modify id counterAction ->
let updateCounter (counterID, counterModel) =
if counterID == id then
(counterID, Counter.update counterAction counterModel)
[...]
as you can see in this code, you're using Counter.Action
and updating the subcomponent by calling its update
with the needed data.
Which gets you a single source of truth, as you always have only one accumulated state of the model.
But.
It gets inconvenient very fast when:
- your app requirements change quickly, because then you have to rewrite a lot of the
Action
s propagation code. - you have a proliferation of subcomponents, then again a lot of
Action
s propagation code needs to be written - you want to keep your subcomponents unaware of each other
Another layer of abstraction
After starting to create my own version of how to organise an Elm app I stumbled on @foxdonut's Composing Features and Behaviours in the Elm Architecture, which achieves what I wanted, while still using StartApp underneath.
The point is, you can have multiple StartApp
s in one program, probably as many as you want.
Each StartApp
is a Component, which he calls Feature, for the sake of using different names not overlapping with TEA.
Each Feature has an input and an output "wire", on the inputs it gets Action
s, on the outputs it sends values that will be transformed into appropriate Action
s for the Feature they're going to be the inputs of.
Think of it as higher level Signal Graph, over the StartApp one, the Features are the nodes, which have inputs and outputs, connected to different nodes with wires.
So each Feature still have init
, Model
, Action
, update
, view
, but in addition they have a Feature.elm
file, where you set up their wiring, and a main file where all Features are set up and wired together.
To create and then connect two or more features together, you need some extra bits of code for each Feature:
- a
Config
type
- a
createFeature
function - a
Services
type - a
Signal.Mailbox
- wire the output with a
Signal.forwardTo
- the appropriate code in
update
to send through the output wire - one file where you wire all the
Feature
s together, I useApp.elm
, and it's the file just underMain.elm
- wire the resulting into
Main.elm
It's a lot to keep track of, but you get completely independent Components and a very flexible structure. It's especially if you have some Components which don't really need to keep their own state, and mainly do side effects (like a connection manager), where it makes little sense to litter Components with extra imports they shouldn't strictly need.
From here I'll guide you on the extra code you need, piece by piece. Reading foxdonut's tutorial takes a while, so this summary should help you grasp this technique without investing a lot of time.
Config
Made of inputs and outputs Lists, inputs are Signal Action
, outputs are Signal.Addresses
of the data you want them to pass around.
type alias Config =
{ inputs : List (Signal Action)
, outputs :
{ onEditTodo : List (Signal.Address Todo)
, onUpdatedList : List (Signal.Address (List Todo))
}
}
notice than on the output there is a naming convention onEventName, later you'll see the same name used in the Services
type, but with a different prefix.
createSomeFeature
The create function takes the Config
above. It uses StartApp.start
, and passes to the update
function the Services
functions, which are used both to perform Tasks
and to broadcast values to the outputs:
createTodoListFeature : Config -> TodoListFeature
createTodoListFeature config =
start
{ init = initialModelAndEffects
, update =
update
{ loadTodos = loadTodos
, deleteTodo = deleteTodo
, signalEditTodo = broadcast config.outputs.onEditTodo
, signalUpdatedList = broadcast config.outputs.onUpdatedList
}
, view = view
, inputs = config.inputs
}
broadcast
is a function in Utils.elm, which Signal.send
some data and Action
to a list of Signal.Address.
Note the use of signalEventName, as in Services
just below. So the functions we use to propagate the outputs in Services
and update
are declared here.
Services
Are passed into update at creation time, as we saw above. Generally declared in Update.elm
.
type alias Services =
{ loadTodos : Task Never Model
, deleteTodo : Int -> Task Never (Maybe Int)
, signalEditTodo : Todo -> Action -> Effects Action
, signalUpdatedList : List Todo -> Action -> Effects Action
}
notice that the naming convention here is signalEventName, of which we saw the complementary in Config
:
Config
: onEventNameServices
: signalEventNamecreate<Some>Feature
: signalEventName
Mailbox
In the file where you wire all the Feature
s you'll need a mailbox
for each:
module TodoMain (todoMainFeature) where
[...]
todoListMailbox : Signal.Mailbox TodoList.Action.Action
todoListMailbox =
Signal.mailbox (ShowList initialModel)
[...]
todoListFeature : TodoListFeature
todoListFeature =
createTodoListFeature
{ inputs = [ todoListMailbox.signal ]
, outputs =
{ onEditTodo = [ Signal.forwardTo todoFormMailbox.address Edit ]
, onUpdatedList = [ ]
}
}
and then wire it in the inputs, in a List
.
Outputs wiring
As we can see above, we use Signal.forwardTo address Action
, so the Action
must belong to the subcomponent it's going to, and take the same type as declared in Services
:
-- Services
signalEditTodo : Todo -> Action -> Effects Action
[...]
-- todoListFeature
, outputs =
{ onEditTodo = [ Signal.forwardTo todoFormMailbox.address Edit ]
here Edit
takes a Todo
:
type Action
= NoOp
| Edit Todo
[...]
Update
In the update we send out the todo
to the output wire, using the service function we passed in, and a NoOp
action.
update services action model =
[...]
EditTodo todo ->
( model, services.signalEditTodo todo NoOp )
This is an improvement on my experience with TEA, because to send an Action
(to a port's javascript side, for example) I had to make up an extra NoOp ()
action in the past, to make the program typecheck.
Wire all the Features
As mentioned before, all your Features have to be created and wired in the same file, TodoMain.elm
in the example. Where you'll create the mailbox
for each feature and the feature itself:
module TodoMain (todoMainFeature) where
[...]
-- a mailbox for each feature
todoListMailbox : Signal.Mailbox TodoList.Action.Action
todoListMailbox =
Signal.mailbox (ShowList initialModel)
todoFormMailbox : Signal.Mailbox TodoForm.Action.Action
todoFormMailbox =
Signal.mailbox (Edit blankTodo)
-- create each feature
todoListFeature : TodoListFeature
todoListFeature =
createTodoListFeature
{ inputs = [ todoListMailbox.signal ]
, outputs =
{ onEditTodo = [ Signal.forwardTo todoFormMailbox.address Edit ]
, onUpdatedList = [ ]
}
}
todoFormFeature : TodoFormFeature
todoFormFeature =
createTodoFormFeature
{ inputs = [ todoFormMailbox.signal ]
, outputs =
{ onSaveTodo =
[ Signal.forwardTo todoListMailbox.address UpdateList
]
}
}
Then you have to combine together the view
output, the tasks
and you can create the bigger feature that is a container for the sub-features we just created:
todoMainView : Html -> Html -> Html
todoMainView todoListView todoFormView =
div
[]
[ todoFormView
, todoListView
]
html : Signal Html
html =
Signal.map2 todoMainView todoListFeature.html todoFormFeature.html
tasks : Signal (Task Never ())
tasks =
Signal.mergeMany
[ todoListFeature.tasks
, todoFormFeature.tasks
]
todoMainFeature =
{ html = html
, tasks = tasks
}
Main wiring
Which then has to be wired into Main.elm
like this:
module Main (..) where
[...]
main : Signal Html
main =
todoMainFeature.html
port tasks : Signal (Task Never ())
port tasks =
todoMainFeature.tasks
And that's it! Hopefully.
Ping me on twitter or on the Elm Slack for any questions/fixes.
Comments? Give me a shout at @lambda_cat.
To get the latest post updates subscribe to the LambdaCat newsletter.
You can support my writing on LambdaCat's Patreon.