Road to Elm - Structuring your application

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 Actions propagation code.
  • you have a proliferation of subcomponents, then again a lot of Actions 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 StartApps 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 Actions, on the outputs it sends values that will be transformed into appropriate Actions 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:

  1. 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 Features together, I use App.elm, and it's the file just under Main.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: onEventName
  • Services: signalEventName
  • create<Some>Feature: signalEventName

Mailbox

In the file where you wire all the Features 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.