bstro / elm-architecture-tutorial

How to create modular Elm code that scales nicely with your app

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

The Elm Architecture

This tutorial outlines the general architecture you will see in all Elm programs, from TodoMVC to dreamwriter.

We will learn a very simple architecture pattern that serves as an infinitely nestable building block. It is great for modularity, code reuse, and testing. Ultimately, this pattern makes it easy to create complex webapps in a way that stays modular. We will start with the basic pattern in a small example and slowly build on those core principles.

One very interesting aspect of this architecture is that it emerges from Elm naturally. The language design itself leads you towards this architecture whether you have read this document and know the benefits or not. I actually discovered this pattern just using Elm and have been shocked by its simplicity and power.

Note: To follow along with this tutorial with code, install Elm and fork this repo. Each example in the tutorial gives instructions of how to run the code.

The Basic Pattern

The logic of every Elm program will break up into three cleanly separated parts: model, update, and view. You can pretty reliably start with the following skeleton and then iteratively fill in details for your particular case.

-- MODEL

type alias Model = { ... }


-- UPDATE

type Action = Reset | ...

update : Action -> Model -> Model
update action model =
  case action of
    Reset -> ...
    ...


-- VIEW

view : Model -> Html
view =
  ...

This tutorial is all about this pattern and small variations and extensions.

Example 1: A Counter

Our first example is a simple counter that can be incremented or decremented. To see it in action, navigate into directory 1/, run elm-reactor, and then open http://localhost:8000/Counter.elm?debug.

This code starts with a very simple model. We just need to keep track of a single number:

type alias Model = Int

When it comes to updating our model, things are relatively simple again. We define a set of actions that can be performed, and an update function to actually perform those actions:

type Action = Increment | Decrement

update : Action -> Model -> Model
update action model =
  case action of
    Increment -> model + 1
    Decrement -> model - 1

Notice that our Action union type does not do anything. It simply describes the actions that are possible. If someone decides our counter should be doubled when a certain button is pressed, that will be a new case in Action. This means our code ends up very clear about how our model can be transformed. Anyone reading this code will immediately know what is allowed and what is not. Furthermore, they will know exactly how to add new features in a consistent way.

Finally, we create a way to view our Model. We are using elm-html to create some HTML to show in a browser. We will create a div that contains: a decrement button, a div showing the current count, and an increment button.

view : Model -> Html
view model =
  div []
    [ button [ onClick (Signal.send actionChannel Decrement) ] [ text "-" ]
    , div [ countStyle ] [ text (toString model) ]
    , button [ onClick (Signal.send actionChannel Increment) ] [ text "+" ]
    ]

countStyle : Attribute
countStyle =
  ...

The tricky thing about our view function is the Signal.send actionChannel part. We will dive into that in the next section! For now, I just want you to notice that this code is entirely declarative. We take in a Model and produce some Html. That is it. At no point do we mutate the DOM manually, which gives the library much more freedom to make clever optimizations and actually makes rendering faster overall. It is crazy. Furthermore, view is a plain old function so we can get the full power of Elm’s module system, test frameworks, and libraries when creating views.

This pattern is the essense of architecting Elm programs. Every example we see from now on will be a slight variation on this basic pattern: Model, update, view.

Aside: Driving your App with Signals

Now to understand the Signal.send actionChannel snippet.

So far we have only been talking about pure functions and immutable data. This is great, but we also need to react to events in the world. This is the role of signals in Elm. A signal is a value that changes over time, and it lets us talk about how our Model is going to evolve.

Pretty much all Elm programs will have a small bit of code that drives the whole application. In example 1 the snippet looks like this:

main : Signal Html
main =
  Signal.map view model

model : Signal Model
model =
  Signal.foldp update 0 (Signal.subscribe actionChannel)

actionChannel : Signal.Channel Action
actionChannel =
  Signal.channel Increment

I will just briefly draw your attention to a couple details:

  1. We start with an initial Model of 0.
  2. We use the update function to step our Model forward.
  3. We “subscribe” to the actionsChannel to get all the incoming Actions.
  4. We put it all on screen with view.

Rather than trying to figure out exactly what is going on line by line, I think it is best to start with visualizing what is happening at a high level.

Signal Graph Summary

The blue part is our core Elm program which is exactly the model/update/view pattern we have been discussing so far. When programming in Elm, you can mostly think inside this box and make great progress.

The new thing here is how “channels” make it possible for new Actions to be triggered in response to user inputs. These channels are roughly represented by the dotted arrows going from the monitor back to our Elm program. So when we specify certain channels in our view, we are describing how user Actions should come back into our program. Notice we are not performing those actions, we are simply reporting them back to our main Elm program. This separation is a key detail!

I want to reemphasize that this Signal code is pretty much the same in all Elm programs. It is good to learn more about them, but you should be able to continue with this tutorial with just the high-level picture. The point here is to focus on architecting your code, not to get bogged down in how you get everything running, so lets start extending our basic counter example!

Example 2: A Pair of Counters

In example 1 we created a basic counter, but how does that pattern scale when we want two counters? Can we keep things modular? To see example 2 in action, navigate into directory 2/, run elm-reactor, and then open http://localhost:8000/CounterPair.elm?debug.

Our primary goal here is to reuse all of the code from example 1. To do this, we create a self-contained Counter module that encapsulates all the implementation details. The only change necessary is in the view function, so I have elided all the other definitions which are unchanged:

module Counter (Model, init, Action, update, view) where

type Model = ...

init : Int -> Model
init = ...

type Action = ...

update : Action -> Model -> Model
update = ...

view : LocalChannel Action -> Model -> Html
view channel model =
  div []
    [ button [ onClick (send channel Decrement) ] [ text "-" ]
    , div [ countStyle ] [ text (toString model) ]
    , button [ onClick (send channel Increment) ] [ text "+" ]
    ]

Rather than refering directly to a top-level actionChannel as we did in example 1, we give the channel as an argument so that each counter can be sending messages along different channels. This will let us augment a basic Counter.Action with extra information so that we know which counter needs to be updated.

Creating modular code is all about creating strong abstractions. We want boundaries which appropriately expose functionality and hide implementation. From outside of the Counter module, we just see a basic set of values: Model, init, Action, update, and view. We do not care at all how these things are implemented. In fact, it is impossible to know how these things are implemented. This means no one can rely on implementation details that were not made public.

So now that we have our basic Counter module, we need to use it to create our CounterPair. As always, we start with a Model:

type alias Model =
    { topCounter : Counter.Model
    , bottomCounter : Counter.Model
    }

init : Int -> Int -> Model
init top bottom =
    { topCounter = Counter.init top
    , bottomCounter = Counter.init bottom
    }

Our Model is a record with two fields, one for each of the counters we would like to show on screen. This fully describes all of the application state. We also have an init function to create a new Model whenever we want.

Next we describe the set of Actions we would like to support. This time our features should be: reset all counters, update the top counter, or update the bottom counter.

type Action
    = Reset
    | Top Counter.Action
    | Bottom Counter.Action

Notice that our union type refers to the Counter.Action type, but we do not know the particulars of those actions. When we create our update function, we are mainly routing these Counter.Actions to the right place:

update : Action -> Model -> Model
update action model =
  case action of
    Reset -> init 0 0

    Top act ->
      { model |
          topCounter <- Counter.update act model.topCounter
      }

    Bottom act ->
      { model |
          bottomCounter <- Counter.update act model.bottomCounter
      }

So now the final thing to do is create a view function that shows both of our counters on screen along with a reset button.

view : Model -> Html
view model =
  div []
    [ Counter.view (LC.create Top actionChannel) model.topCounter
    , Counter.view (LC.create Bottom actionChannel) model.bottomCounter
    , button [ onClick (Signal.send actionChannel Reset) ] [ text "RESET" ]
    ]

Notice that we are able to reuse the Counter.view function for both of our counters. For each counter we create a local-channel. Essentially what we are doing here is saying, “let these counters send messages to the general actionChannel but make sure all of their messages are annotated with Top or Bottom so we can tell the difference.”

That is the whole thing. With the help of local-channel, we were able to nest our pattern model/update/view pattern. The cool thing is that we can keep nesting more and more. We can take the CounterPair module, expose the key values and functions, and create a CounterPairPair or whatever it is we need.

Example 3: A Dynamic List of Counters

A pair of counters is cool, but what about a list of counters where we can add and remove counters as we see fit? Can this pattern work for that too?

To see this example in action, navigate into directory 3/, run elm-reactor, and then open http://localhost:8000/CounterList.elm?debug.

In this example we can reuse the Counter module exactly as it was in example 2.

module Counter (Model, init, Action, update, view)

That means we can just get started on our CounterList module. As always, we begin with our Model:

type alias Model =
    { counters : List ( ID, Counter.Model )
    , nextID : ID
    }

type alias ID = Int

Now our model has a list of counters, each annotated with a unique ID. These IDs allow us to distinguish between them, so if we need to update counter number 4 we have a nice way to refer to it. (This ID also gives us something convenient to key on when we are thinking about optimizing rendering, but that is not the focus of this tutorial!) Our model also contains a nextID which helps us assign unique IDs to each counter as we add new ones.

Now we can define the set of Actions that can be performed on our model. We want to be able to add counters, remove counters, and update certain counters.

type Action
    = Insert
    | Remove
    | Modify ID Counter.Action

Our Action union type is shockingly close to the high-level description. Now we can define our update function.

update : Action -> Model -> Model
update action model =
  case action of
    Insert ->
      let newCounter = ( model.nextID, Counter.init 0 )
          newCounters = model.counters ++ [ newCounter ]
      in
          { model |
              counters <- newCounters,
              nextID <- model.nextID + 1
          }

    Remove ->
      { model | counters <- List.drop 1 model.counters }

    Modify id counterAction ->
      let updateCounter (counterID, counterModel) =
            if counterID == id
                then (counterID, Counter.update counterAction counterModel)
                else (counterID, counterModel)
      in
          { model | counters <- List.map updateCounter model.counters }

Here is a high-level description of each case:

  • Insert — First we create a new counter and put it at the end of our counter list. Then we increment our nextID so that we have a fresh ID next time around.

  • Remove — Drop the first member of our counter list.

  • Modify — Run through all of our counters. If we find one with a matching ID, we perform the given Action on that counter.

All that is left to do now is to define the view.

view : Model -> Html
view model =
  let counters = List.map viewCounter model.counters
      remove = button [ onClick (Signal.send actionChannel Remove) ] [ text "Remove" ]
      insert = button [ onClick (Signal.send actionChannel Insert) ] [ text "Add" ]
  in
      div [] ([remove, insert] ++ counters)

viewCounter : (ID, Counter.Model) -> Html
viewCounter (id, model) =
  Counter.view (LC.create (Modify id) actionChannel) model

The fun part here is the viewCounter function. It uses the same old Counter.view function, but in this case we provide a local-channel that annotates all messages with the ID of the particular counter that is getting rendered.

When we create the actual view function, we map viewCounter over all of our counters and create add and remove buttons that report to the actionChannel directly.

This ID trick can be used any time you want a dynamic number of subcomponents. Counters are very simple, but the pattern would work exactly the same if you had a list of user profiles or tweets or newsfeed items or product details.

Example 4: A Fancier List of Counters

Okay, keeping things simple and modular on a dynamic list of counters is pretty cool, but instead of a general remove button, what if each counter had its own specific remove button? Surely that will mess things up!

Nah, it works.

To see this example in action, navigate into directory 4/, run elm-reactor, and then open http://localhost:8000/CounterList.elm?debug.

In this case our goals mean that we need a new way to view a Counter that adds a remove button. Interestingly, we can keep the view function from before and add a new viewWithRemoveButton function that provides a slightly different view of our underlying Model. This is pretty cool. We do not need to duplicate any code or do any crazy subtyping or overloading. We just add a new function to the public API to expose new functionality!

module Counter (Model, init, Action, update, view, viewWithRemoveButton, Context) where

...

type alias Context =
    { actionChan : LocalChannel Action
    , removeChan : LocalChannel ()
    }

viewWithRemoveButton : Context -> Model -> Html
viewWithRemoveButton context model =
  div []
    [ button [ onClick (send context.actionChan Decrement) ] [ text "-" ]
    , div [ countStyle ] [ text (toString model) ]
    , button [ onClick (send context.actionChan Increment) ] [ text "+" ]
    , div [ countStyle ] []
    , button [ onClick (send context.removeChan ()) ] [ text "X" ]
    ]

The viewWithRemoveButton function adds one extra button. Notice that the increment/decrement buttons send messages to the actionChan but the delete button sends messages to the removeChan. The messages we send along the removeChan are essentially saying, “hey, whoever owns me, remove me!” It is up to whoever owns this particular counter to do the removing.

Now that we have our new viewWithRemoveButton, we can create a CounterList module which puts all the individual counters together. The Model is the same as in example 3: a list of counters and a unique ID.

type alias Model =
    { counters : List ( ID, Counter.Model )
    , nextID : ID
    }

type alias ID = Int

Our set of actions is a bit different. Instead of removing any old counter, we want to remove a specific one, so the Remove case now holds an ID.

type Action
    = Insert
    | Remove ID
    | Modify ID Counter.Action

The update function is pretty similar to example 4 as well.

update : Action -> Model -> Model
update action model =
  case action of
    Insert ->
      { model |
          counters <- ( model.nextID, Counter.init 0 ) :: model.counters,
          nextID <- model.nextID + 1
      }

    Remove id ->
      { model |
          counters <- List.filter (\(counterID, _) -> counterID /= id) model.counters
      }

    Modify id counterAction ->
      let updateCounter (counterID, counterModel) =
            if counterID == id
                then (counterID, Counter.update counterAction counterModel)
                else (counterID, counterModel)
      in
          { model | counters <- List.map updateCounter model.counters }

In the case of Remove, we take out the counter that has the ID we are supposed to remove. Otherwise, the cases are quite close to how they were before.

Finally, we put it all together in the view:

view : Model -> Html
view model =
  let insert = button [ onClick (Signal.send actionChannel Insert) ] [ text "Add" ]
  in
      div [] (insert :: List.map viewCounter model.counters)

viewCounter : (ID, Counter.Model) -> Html
viewCounter (id, model) =
  let context =
        Counter.Context
          (LC.create (Modify id) actionChannel)
          (LC.create (always (Remove id)) actionChannel)
  in
      Counter.viewWithRemoveButton context model

In our viewCounter function, we construct the Counter.Context to pass in all the nesessary local channels. In both cases we annotate each Counter.Action so that we know which counter to modify or remove.

Big Lessons So Far

Basic Pattern — Everything is built around a Model, a way to update that model, and a way to view that model. Everything is a variation on this basic pattern.

Nesting Modules — A local-channel makes it easy to nest our basic pattern, hiding implementation details entirely. We can nest this pattern arbitrarily deep, and each level only needs to know about what is going on one level lower.

Adding Context — Sometimes to update or view our model, extra information is needed. We can always add some Context to these functions and pass in all the additional information we need without complicating our Model.

update : Context -> Action -> Model -> Model
view : Context' -> Model -> Html

At every level of nesting we can derive the specific Context needed for each submodule.

Testing is Easy — All of the functions we have created are pure functions. That makes it extremely easy to test your update function. There is no special initialization or mocking or configuration step, you just call the function with the arguments you would like to test.

One Last Pattern

There is one last important way to extend the basic pattern. For example, maybe you have a component that gets updated, and depending on the result, you need to change something else in your program. You can extend your update function to return extra information.

type Request = RefreshPage | Print

update : Action -> Model -> (Model, Maybe Request)

Depending on the logic of the update we may be telling someone above us to refresh the content or print stuff out. The same sort of pattern can be used if a component can delete itself:

update : Action -> Model -> Maybe Model

If this is not clear, maybe I will write example 5 that shows this pattern in action. In the meantime, you can see examples like this in the fancy version of the TodoMVC app written in Elm.

About

How to create modular Elm code that scales nicely with your app

License:BSD 3-Clause "New" or "Revised" License


Languages

Language:Elm 100.0%