Page state in Elm
2019-09-16

I received a question along the lines of the following:

Suppose I have two pages, one for a product list (received from the server) and another to show a listing for an individual product. How do I pass the product to the product page once it’s selected on the list page?

I remember that coming from JavaScript, I was also puzzled by similar scenarios, so let’s start with something like a JavaScript representation of the state, and refactor it into an Elm-like model.

In JavaScript, our model might look something like this:

{ page: "product" // could also be "list"
, products: [ { name: "Product 1", ... }, { name: "Product 2", ... }, ... ]
, selectedProductIndex: 5
}

Translated to Elm, the type of the model would be:

type alias Model = 
    { page : String
    , products : List Product
    , selectedProductIndex : Int
    }

This has a couple of issues:

  • Since page is just a string, we could have an invalid value for the page
  • selectedProductIndex could be out of bounds or out of date.

However, Elm’s type system allows us to express these constraints in a way that eliminates invalid state.

First, let’s define a custom Page type:

type Page 
    = ListPage
    | ProductPage

type alias Model = 
    { page : Page
    , products : List Product
    , selectedProductIndex : Int
    }

Now we can only have a valid page in the model, but we can still have an invalid product index.

Logically, selectedProductIndex doesn’t even make sense when we’re on the product list page. There is no selected product there!

The cool thing about custom types in Elm is that each tag can also be associated with a value. This means that we can move the index from the model record into the Page type:

type Page 
    = ListPage
    | ProductPage Int

type alias Model = 
    { page : Page
    , products : List Product
    }

Now it only exists when the page value is ProductPage!

That’s useful, but let’s consider the view code now:

view model = 
    case model.page of 
        ListPage -> 
            showProductList model.products
        ProductPage index ->
            showProduct index

showProductList products =
    div [] <|
        List.map (\product -> 
            div [ onClick <| UserSelectedProduct ??? ] [ ... ] 
            ) products

We need to pass the index to showProduct, and we need to produce UserSelectedProduct messages in showProductList. However, what’s readily available in showProductList is the product value rather than its index. In addition, this still leaves the possibility of having an invalid index.

So actually, the simplest thing to do is to pass the product value around, and get rid of the index altogether:

type Page 
    = ListPage
    | ProductPage Product

type alias Model = 
    { page : Page
    , products : List Product
    }


view model = 
    case model.page of 
        ListPage -> 
            showProductList model.products
        ProductPage product ->
            showProduct product

showProductList products =
    div [] <|
        List.map (\product -> 
            div [ onClick <| UserSelectedProduct product ] [ ... ] 
            ) products


update msg model = 
    case msg of 
        ...
        UserSelectedProduct product -> 
            ( { model | page = ProductPage product }, Cmd.none )
        ...

Note how model.page is set in the update function. With this setup, it’s no longer possible to have an invalid index!

Conclusion

This post illustrates a couple of key design principles for Elm code:

  • Use the facilities of the type system to express the structure of the data
  • Constrain the values that can be created so as to make invalid states impossible.
Would you like to dive further into Elm?
📢 My book
Practical Elm
skips the basics and gets straight into the nuts-and-bolts of building non-trivial apps.
🛠 Things like building out the UI, communicating with servers, parsing JSON, structuring the application as it grows, testing, and so on.
Practical Elm