DEV Community

Richard Wood for ElmUpdate

Posted on

Keeping it simple beyond a basic Elm app

When you want to go beyond a basic Elm code base people will usually point you to Richard Feldman’s elm-spa-example.

Unless you are confident you are heading for a multipage, feature-rich extravaganza then this is overkill.

Yes, it proves that in Elm you actually can break things into subcomponents, delegating models, messages and subscriptions, which in no way means you should.

On the other hand, it's also been said that you can just put everything in one file with a huge update function with hundreds of lines. Um. Seriously, you probably don’t want to be waiting for that to scroll or end up jumping to specific meaningless line numbers.

Recently I had a mid-size web app to start building so I sought to navigate a middle path. Starting with some basic principles.

  1. Keep it simple - future maintenance depends on it.
  2. Minimise meaningless abstractions and patterns - code that doesn't do what the app is trying to do wastes everyone's time.
  3. Don't fight The Elm Architecture - update function changes model is changed and calls commands. Model changes are reflected in views. View events, command results, and subscriptions lead to more updates. It's a virtuous and relatively tidy one-way circle.
  4. Avoid nesting. Keep the single source of truth model as flat as possible. Remember that all events flow through the main update even if delegated.
  5. Don't make it difficult for components to interact. A web app will generate events in one corner that lead to model changes and commands affecting other parts of the app. Subviews will need access to the top level and other component's data.
  6. Make it easy to find relevant code.

I began with create-elm-app with a one file solution, then upgraded to Browser.application when I needed access to the Url.

When I felt the update function was getting a bit scrolly and I had a user editing section that was burgeoning I moved that into its own file. It was at this point that I had to decide how far to go with delegation.

Interestingly, and something I’ve found previously, is that it is pretty easy to get into a situation where you need to call back and forth to your main file for type and message definitions - leading to circular references.

I’ve found in the past that this can be knocked on the head by putting all types in a file called Types, all msgs in a file called Msgs and common functions in a file called Helpers or a file called CommonViews accordingly.

So I went ahead with this refactor. This worked in tune with not going crazy on delegation. What we are trying to avoid is stuff that makes you think too hard, like this from elm-spa-example:

updateWith : (subModel -> Model) -> (subMsg -> Msg) -> Model -> ( subModel, Cmd subMsg ) -> ( Model, Cmd Msg )
updateWith toModel toMsg model ( subModel, subCmd ) =
    ( toModel subModel
    , toMsg subCmd

Noone should have to read that!

In a nutshell I went with a delagation for the update function but not for anything else.

So in the main update:

case msg of
    ToEnv envMsg ->
        Env.update envMsg model

    ToUsers userMsg ->
        Users.update userMsg model


The wrapped messages for the components do often mean I am doing component composition (<<) to reference them.

In the Types file the main model definition is broken into component sections by using comment separators.

This helps keeps a lid on nesting and keeps the single source of truth reality front-of-mind.

It means also one equivalent init function, albeit referencing empty component-specific records, which for now I am holding in their respective component files.

type alias Model =
    { window : Window
    , messageToUser : Maybe String
    , env : Env
    , initialUrl : Url
    , navKey : Key

    -- User
    , users : List User
    , userSearchQuery : String

For the Msgs file there is a main message type for the main file that includes the component wrappers, and separate message types for each of the components.

type Msg
    = ResizeDevice Int Int
    | ToEnv EnvMsg
    | ToUsers UserMsg

type EnvMsg
    = GetEnv
    | EnvResult (Result Http.Error ConfigEnv)

type UserMsg
    = ClearSearchUsers
    | SearchUsers

I could have put component messages in with the rest of the component. However it has been useful to keep all possible messages in one view as far as possible.

The net effect of this is that the main has a very clean update function showing the structure of the application, without meaningless absractions.

The component file have their own update function that works off their own message type and comes back to the single source of truth model either through top level of any component messages.

update : UserMsg -> Model -> ( Model, Cmd Msg )
update userMsg model =
    case userMsg of
        SearchUsers ->

There is a little bit of nesting access needed in the component but only to one level so it’s manageable.

The component files also hold the related views for those components.

I haven’t found a reason yet in my app for delegating subscriptions, perhaps because I don’t have many yet. I also am not doing much with routing yet.

Otherwise, overall I am very pleased with the result for a small to mid-size app. There is a minimisation of scaffolding. It has been very easy to be able to find, add to and refactor code.

Discussion (0)