Inside-Out Elm Views

Modularizing Elm programs is a hot topic. The general advice these days is to start from a single file, add functionality freely, and when things feel like they’re getting too big, leverage Elm’s crazy refactorability to organically extract out helper functions and modules.

If you’ve been following the community for the past two years or so, you’ll know that this advice is in part a reaction to the “component” or “fractal TEA” modularization style that is persistently intuitive to people: each program module mirrors the interface of the program itself, with its own Model, Msg, init, update, and view (I’ll refer to modules with this interface as “component”s from now on). As anyone who’s tried this knows, forcing every module in your program to implement this interface is way more trouble than it’s worth. 

Letting things evolve naturally is a fun and productive way to program, and it rightly tempers the pull towards componentizing everything. But there’s a slight wrinkle here: Despite their downsides, “component”-style interfaces are appropriate sometimes, and the mantra “let things evolve naturally” doesn’t offer any insight into how to make them easier to work with when the situation calls for it.

I encountered just such a situation in building out our account management UI at Sentenai. Around the same time, while tunneling down an unrelated rabbit hole in my spare time, I stumbled upon something unexpected: With a moderate conceptual shift, and a corresponding change to the type of the view function, “component”s as I knew them got turned on their heads, and the thing that was left seemed much more flexible and composable.

Rather than excavate that rabbit hole in full, I’m going to introduce the technique from first principles, building up from a realistic example, with the hope that you’ll learn enough to try it out in your own projects. I’ll assume you’re familiar with intermediate-advanced Elm concepts like higher-order functions, closures, and opaque types, and have written enough Elm to have an intuition for when “component”s are appropriate. Beginners without that grounding should stop here, check out the links above, then go build something cool :)


Suppose we’re working on an elm application with the following structure.

src/Main.elm
src/Dashboard.elm
src/OrderFruit.elm

The app is a SPA that began life as a single file. After some work, we noticed that chunks of the Msg and Model associated with the Dashboard and OrderFruit pages were sufficiently complicated, and isolated enough from each-other, that we could cleanly split them into separate modules, each with its own Msg, Model, init, update, and view. Main is now the place that sews the two pages together with any additional behavior. 

Let’s extend the UI of this application twice, and see what happens. 

Exhibit A) Rendering Up the DOM

In our Dashboard, we now want to use Kris Jenkins’ lovely elm-dialog package to pop up a modal in a certain state. But there’s a problem. It says in the docs for that package that the modal needs to be a child of the root DOM node, but we can’t make this happen! We’re in Dashboard.elm, with a view function view : Dashboard.Model -> Html Dashboard.Msg; we don’t have any control over the broader context in which the function is called, so we don’t have any way to enforce the library’s invariant. 

The best we can do is give whoever is calling us the tools to enforce it themselves. 

This way, Main can decide what to do with the modal if Dashboard has provided it. 

Exhibit B) Feed me messages

Suppose now that we have a new requirement: we have to add a button to the dashboard that begins the fruit-ordering flow, but Main contains the logic for handling the OrderFruit message and switching pages. 

Now, we have to ask Main for some more context: the message in question, and also a function that maps our messages into some unknown parent message type.

Here’s the new Dashboard 

And the new Main:

To sum up: in response to two changes to the UI, we’ve had to change the API of the module that deals with that chunk of UI. We’ve met our requirements, but to me, the path we took to get there was not ideal. 

Context Creep

In a certain sense, the fact that the modal has to be rendered separately from the dashboard has nothing to do with the core business logic of the module; It’s a requirement that the main view is imposing on us from outside  —  an implementation detail that we’re forced to tack on to our public interface. In this sense, the Dashboard module is coupled to the Main module, and therefore isn’t very modular. 

This an instance of a broader problem that I call context creep: Over time, after extracting a “component” with a clean interface, it gradually grows to accommodate extra context that couples it to certain callers, making it harder to think about by itself, and harder to reuse.

If your cognitive bottleneck and main motive for modularizing code is file size, as opposed to reusability, then context creep isn’t that big of an issue. Even in my example, It’s hard to imagine an app that might want to re-use the same Dashboard page in different contexts, so that final version of the code is more than good enough for government work. 

But consider something that could be reused, like a login flow. Depending on each calling context, you might have to expose different view functions that take different sets of arguments, or return different chunks of Html. Sure, those different view functions could be sharing as much code as possible under the hood, but knowledge about what gets rendered in which context is continually spread between the module’s file and the file that imports it. This means you’ll have to swap between files a bunch to get the full picture of what’s going on.  

The gradual process of context creep has another cost: Every time we make a change to the API of Dashboard, we’ve got to tweak the type signature of its functions, follow the compiler errors, and add the boilerplate for packaging and unpackaging its inputs and outputs. 

I feel quite bratty calling this a problem; it’s why we love Elm in the first place! The type system makes the “change the types and follow the red squiggles” workflow a great way to program, easing tough refactors considerably, and enabling refactors that other technologies simply can’t support. 

In real terms though, this is still friction. If we can reduce this friction, we’ll have an even smoother programming experience.

Deferring View Decisions

Context creep stems from the fact that Dashboard.view is making most of the decisions about how to render its state, but its caller keeps wanting to micromanage those decisions in finer and finer detail.

There’s a simple answer to this problem: have Dashboard quit the view-writing business completely. 

Look what happens when we leave out Dashboard.view, and open up the module’s guts to the world:

Now, Main.view picks apart the entire application state as a whole, including the Dashboard's, and decides when to show the dashboard, when to pop the modal up, and when to ask the user to order some fruit. This is kind of trippy, but look closely, and you’ll notice that the issues that create context creep have essentially gone away

  • We can simultaneously render the modal and the dashboard as separate children of the document root.
  • We can freely mix and match Dashboard.Msgs with Main.Msgs across the two DOM nodes, without having to explicitly pass them as arguments to some function.

In essence: all the surrounding context we could ever want to render the Dashboard with is already in scope, so if something changes, we won’t have to write down new type signatures and chase down type errors between two modules if we don’t want to. 

Additionally, there’s no longer a need to store a Modal in Main.Model and thread updates to it from Dashboard.update. We can choose when, where, and how to show the modal purely as a function of the Dashboard’s state. My intuition is that this clarification of abstractions, with “business logic” solely in update and “presentation” solely in view, is a big understandability win. 

On the other hand, changes to the Dashboard’s Model and Msg will now cause type errors in Main. You would’ve gotten these type errors before, they just would’ve been localized to Dashboard.elm

However cool this might be, any seasoned Elm programmer will tell you that there are now some serious problems lurking in update. Because Dashboard.Msg and Dashboard.Model are not opaque, Main.update can do a bunch of nasty things with their values: it can feed messages directly to Dashboard.update at will, or bypass it entirely and instantiate arbitrary Dashboard states out of thin air. These are abilities that are actively harmful if we’re counting on Dashboard.update to be the source of truth for how itsModels and Msgs interact.

Surprisingly, we can fix these problems, and get all the benefits of the fully exposed world with all the safety of the opaque world. 

Safety with Higher Order Functions

Let’s tackle each issue one at a time. To prevent callers from messing with models, we introduce a new datatype, call itState, that they can pattern match on in their view, but they cannot use to update the Model. Implementation-wise, it might look something like this

module Dashboard exposing (Model, Msg, State(..), view, init, update)
type alias State = 
{ fruits : List Fruit
, selectedFruit : Maybe String
}
-- State contains what Model would have contained, but we 
-- expose it, and make Model opaque
type Model = 
Model State

state : Model -> State
state (Model s) =
s
update : Msg -> Model -> (Model, Cmd Msg)
update msg (Model s) = ...

Now, Main will call Dashboard.view to get a data structure it can generate Html from, but that data structure is meaningless to the public API of Dashboard.update, and therefore can’t be used to direct the Dashboard's behavior over time. 

However! This doesn’t prevent Main from using the result of Dashboard.view to write its own update logic. Consider the following implementation of Main.update 

module Main
...
update : Msg -> Model -> Model
update msg model =
case msg of
SomeMsg ->
case Dashboard.state model.dashboard of
ViewingDashboard -> SomeOtherWeirdState
...

I can’t think of situation where you’d need to allow this. To prevent it, we need some way for Dashboard to say to Main “you can have access to my model, so long as you only use it to build Html”. We do this by making view a higher-order function:

module Dashboard exposing (Model, State(..), view)
.. same as above ..
view : Model -> (State -> Html msg) -> Html msg
view (Model state) f =
f state

Instead of exposing the function from Model to State directly, we use the higher-order function to ask Main “if you had access to the result of such a function, what Html would you make?”. Equipped with the function they provide us, and a Model, we pull the state out of the model, and apply the function to it. 

Because the higher-order-function they pass in can be a closure, it can be implemented with all the data and functions that are available in the caller’s scope. Relative to the world where Dashboard.Model is totally exposed, callers haven’t lost an inch of flexibility in deciding what to render based on the Dashboard’s state. 

The only things the client doesn’t have access to are our Msgs, so we include them as another argument to the higher-order view function. The final implementation comes out like this:


The same invariant still holds: Main has unlimited access to our messages, which they can compose arbitrarily with their own messages and those from other modules, but only when the ultimate result of that composition is a chunk of Html. The view they write looks almost identical to the one in unexposed-land, except for the fact that it’s embedded within the function passed to Dashboard.view 

Callers are still free to call this freaky view function within their own update, but they can’t make use of the result in a way that breaks our API contract. They’re also free to call Dashboard.update within the higher-order view function, and try to commit shenanigans by embedding the result in an event handler, but this is such a chore that I doubt it poses much risk to the sanctity of the application.

Deferred Views: Pro and Con

I’ve been struggling to decide what to call this pattern, as it’s not exactly a new thing. If you squint with your React glasses on, the higher order function passed to view looks like a render prop. An earnest Haskeller might describe that same higher order function as a “continuation”, and view itself as a morphism in the Kleisli category induced by the continuation monad. Neither lingo seems like a perfect fit; for our purposes here in Elm land, I think calling Dashboard a module with a deferred view conveys the right intuition. 

Instead of deciding how to render a piece of state with only knowledge that we explicitly ask for, we’re deferring that decision until all the context we’ll ever need is already on hand. At that point, we can experiment with different view implementations without having to constantly maintain a hodgepodge of inputs and outputs.

It’s not a free lunch though; consider the costs and benefits as you would any other programming technique. Here’s a quick overview:

Costs

  • Boilerplate: Defining Model in terms of View, packaging up Msgs and writing the deferred view interface are all new things module authors now need to do. 
  • Slightly more annoying state management changes: View and Msgs are now public API that break clients when they change, rather than just breaking the modules they’re defined in.
  • Extra ease in creating “component” style modules might lead to their overuse.

Benefits

  • Easier aesthetic changes: Spend less time chasing down type errors 
  • More rendering flexibility: Render a module’s state up and down the dom, using exactly as much extra context as you need.
  • Conceptual simplicity: Think about business logic independently of rendering details.
  • Cleaner dependency trees: Submodules don’t need to depend on types or shared views from all over the app (They barely even need to depend on theHtml module!)

This cost/benefit profile suggests a rule of thumb: if your views are changing more often than your Models and Msgs, then you might benefit from this approach. In any event, it needs more testing, so try it out in your own apps and tell me how it goes. I’m eager for feedback here, on the Elm slack, discourse, and on twitter.