Back to Tech Center

Understanding UI Components in Elm

December 28, 2022

Tech Center

Since the Falcon LogScale web client is built in Elm, I’d like to share some of our learnings with Elm over the years. Specifically, working with UI components — a common sticking point for new Elm developers.

The main points I’ll cover:

  • Some architectural considerations and tradeoffs for components in Elm
  • Building vocabulary for describing and comparing components in Elm

This is based on best practices and insights we’ve learned with Elm, having many developers working on a single large application. The post assumes you have a basic understanding of the Elm language1 and The Elm Architecture.

Let’s dive in!

What are components?

Unlike many other UI systems, Elm doesn’t have any notion of “components.” So while it’s easy enough to agree on what it means to be a web component, for example, Elm leaves it up to us to decide what “component” actually means. The definition I want to use is that a component is “a visual building block.” In other words, a component is a piece of reusable UI. This is how our graphic and UX designers think of components, and it works nicely for our developers at Falcon LogScale, too.

Then we get to different ways of implementing components in Elm. For this post, I want to focus on two camps:

  1. Stateful components
  2. Stateless components

The reason these are significant is because Elm is a “pure” functional language, where all state must be handled very explicitly. If a component has state, it must also have a bunch of machinery for maintaining that state, which can mean a lot of work for developers. But being able to keep and update state also allows stateful components to encapsulate “behavior.” And by “behavior” I mean the component can have its own response to e.g. a user clicking a button or getting data from the server.

By contrast, stateless components are often much simpler to write and use, as they can’t respond to anything; they can only do rendering. Of course, our app still needs to respond to user interactions, and stateless components push this responsibility to their parent, as opposed to dealing with it themselves.2

At Falcon LogScale, we prefer working with stateless components over stateful ones. Our experience is that using stateless components makes for less work and better code most of the time. However, it’s important to weigh the tradeoffs between being stateless or stateful, because stateful components are still the right choice sometimes.

Let’s see them in action.

Stateful components are boilerplate magnets

We’ll start by looking at what it means to be a stateful component in Elm. As an example, let’s say we have a stateful component for a checkbox, and it keeps track of whether it’s checked or not. For a component to have state, it must have a Model where that state is stored, and it needs an update function to update the Model, and so on. So we’ll create a Checkbox module, containing roughly this code:

module Checkbox exposing (Model, initUnchecked, update, view)

type alias Model = 
    { isChecked : Bool }

-- Our component can trigger a single message
type Msg = CheckboxClicked

initUnchecked : Model
initUnchecked =
    { isChecked = False }

update : Msg -> Model -> Model
update msg model =
    case msg of
        CheckboxClicked ->
            { isChecked = not model.isChecked }
            
view : Model -> Html Msg
view model =
    Html.input 
        [ Attr.type_ "checkbox"
        , Attr.checked model.isChecked
        , Events.onClick CheckboxClicked
        ]
        []

This can seem reasonable on its own. The component is well encapsulated, and the API mirrors that of a full Elm application, which is a nice bit of symmetry. Let’s try and use it.

Say we need users to accept our terms and conditions in a few places, along with accepting marketing communication. We’ll make a component for that in a module called AcceptTermsAndConditions. Our component will just be a thin wrapper around two checkboxes in this case.

module AcceptTermsAndConditions exposing (Model, initUnchecked, update, view)

type alias Model = 
    { acceptTermsAndConditionsCheckbox : Checkbox.Model
    , acceptMarketingCheckbox : Checkbox.Model 
    }

type Msg
    -- Since each `Checkbox` can generate messages,
    -- we create wrapper messages so we can distinguish
    -- which checkbox actually triggered a given message.
    = TermsAndConditionCheckboxMsg Checkbox.Msg
    | MarketingCheckboxMsg Checkbox.Msg

initUnchecked : Model
initUnchecked =
    { acceptTermsAndConditionsCheckbox = Checkbox.initUnchecked
    , acceptMarketingCheckbox = Checkbox.initUnchecked
    }

update : Msg -> Model -> Model
update msg model =
    case msg of
        -- We must explicitly update the right `Checkbox` model,
        -- depending on which `Checkbox` generated a message.
        TermsAndConditionCheckboxMsg subMsg ->
            { model | acceptTermsAndConditionsCheckbox = 
                Checkbox.update subMsg model.acceptTermsAndConditionsCheckbox
            }
            
        MarketingCheckboxMsg subMsg ->
            { model | acceptMarketingCheckbox = 
                Checkbox.update subMsg model.acceptMarketingCheckbox
            }
            
view : Model -> Html Msg
view model =
    Html.div []
        -- The `Checkbox` component can trigger messages via its HTML,
        -- so we use `Html.map` to wrap those messages with our wrappers.
        [ Checkbox.view acceptTermsAndConditionsCheckbox
            |> Html.map TermsAndConditionCheckboxMsg
        , Checkbox.view acceptMarketingCheckbox
            |> Html.map MarketingCheckboxMsg
        ]

As you can see, there’s a lot of boilerplate code just to have two checkboxes. The reason: when our checkbox component triggers a message, that message must travel from the root of the component tree down to the component, which should handle that message. That is, Checkbox.Msg needs to reach Checkbox.update. And for the message to get there, every component between the root and the target must explicitly pass the message to the next component in the tree.

In other words, if a stateful component A is used in component B, and B is used in component C, then:

  • B needs a message type to wrap messages from A
  • C needs a message type to wrap messages from B

This also means when a stateful component is used inside other components, the statefulness actually spreads to all its new ancestors.3 That’s because stateless components don’t have their own messages. But with a stateful component inside, now they must have. And when a parent has to change to become stateful, any other place where it’s already used must also change so the work can easily balloon. And that’s only handling the communication into the component. We also need communication out of the component.

In our example, let’s say accepting terms and conditions is a prerequisite for a “Submit” button to be enabled, so we need to verify that this checkbox is checked. To let parents inspect the component state, we would need a Checkbox.isChecked function, which AcceptTermsAndConditions can call, and then we need an AcceptTermsAndConditions.areTermsAccepted function, which its parent can call, and so on, in order to pass state up the chain of parent components.

Long story short, the stateful component seems fairly innocent in itself, but it forces a lot of boilerplate on its ancestors. If it gets to spread, it can really impact your speed of development, both because the boilerplate itself takes time to write and maintain, but also because it adds noise and friction to all the code around it. It’s important to note, though, that the amount of boilerplate code you need depends on how many ancestors a component has.

We’ll get back to this point, but let’s first look at how Checkbox  and AcceptTermsAndConditions  might look as stateless components. 

Statelessness to the rescue!

Let’s see our previous components implemented statelessly for comparison. First the checkbox:

module Checkbox exposing (view)

view : Bool -> msg -> Html msg
view isChecked onClick =
    Html.input 
        [ Attr.type_ "checkbox"
        , Attr.checked isChecked
        , Events.onClick onClick
        ]
        []

and then AcceptTermsAndConditions:

module AcceptTermsAndConditions exposing (view)

view : 
    { isTermsAndConditionsChecked : Bool
    , isMarketingChecked : Bool
    , onTermsAndConditionsClicked : msg
    , onMarketingClicked : msg
    } 
    -> Html msg
view params =
    Html.div []
        [ Checkbox.view 
            params.isTermsAndConditionsChecked
            params.onTermsAndConditionsClicked
        , Checkbox.view 
            params.isMarketingChecked
            params.onMarketingClicked
        ]

That’s it! As I noted earlier though, we’ve now pushed much of the required work to the parent, instead of encapsulating it with a stateful component. This can seem bad on the surface, but as we’ve seen stateful components also require a lot of work from all their ancestors. So, in comparison, pushing up state management to the parent is actually less work for every component involved in this case. It also allows the parent to model the state in whichever way makes sense. For example, not all checkboxes need their own boolean.

To see what I mean, let’s add another checkbox to the AcceptTermsAndConditions component. We would like more people to get our marketing emails, so we’re adding an “Accept all” checkbox, which will check or uncheck all the checkboxes when clicked. Additionally, if the user manually checks the other checkboxes, “Accept all” will automatically become checked. 

Let’s expand our stateless component:

module AcceptTermsAndConditions exposing (view)

view : 
    { isTermsAndConditionsChecked : Bool
    , isMarketingChecked : Bool
    -- New message
    , onAcceptAllClicked : msg
    -- Old messages
    , onTermsAndConditionsClicked : msg
    , onMarketingClicked : msg
    } 
    -> Html msg
view params =
    Html.div []
        -- New checkbox first
        [ Checkbox.view
            (params.isTermsAndConditionsChecked && params.isMarketingChecked)
            params.onAcceptAllClicked
        -- Old checkboxes below
        , Checkbox.view 
            params.isTermsAndConditionsChecked
            params.onTermsAndConditionsClicked
        , Checkbox.view 
            params.isMarketingChecked
            params.onMarketingClicked
        ]

We’re passing in a new message, but there’s no new state. Instead, the state for the Accept all” checkbox can be derived from the existing state. But if Checkbox had been stateful, “Accept all” would have had its own boolean that needed updating when you clicked any of the checkboxes. This adds complexity and risk of the different states getting out of sync with each other. The stateless design avoids that, as we can “mold” any existing state to render the checkbox, which we do in our example by combining the booleans from the other checkboxes. However, while stateless components are good for avoiding inconsistent state, they carry another risk of bugs — inconsistent behavior.

Everywhere we use AcceptTermsAndConditions, some parent must now implement what should happen when the onAcceptAllClicked message is triggered. In particular, what if “Accept terms and conditions” is checked, and “Accept marketing” is not when the user clicks “Accept all?” Do we select or deselect everything? If we use AcceptTermsAndConditions in multiple places, it’s easy to imagine that different implementations of onAcceptAllClicked would become inconsistent.

We can help ourselves by creating a function to call anywhere we respond to the onAcceptAllClicked message to flip the booleans consistently. This function would be easy to get good test coverage for. And it reduces our risk of bugs. The only risks are now someone hooking this function up wrong or not using the function.

We can try iterating on this design to bring down the risks further. But regardless of what we do, we must keep weighing the tradeoffs, because we can’t completely remove the risk of inconsistent behavior without negating some of the benefits of statelessness in some way.

When to use stateful components

But maybe AcceptTermsAndConditions should be a stateful component instead of stateless then? When does the room for bugs with a stateless component grow too large to no longer be worth the benefits? 

To find out which way to go, it’s good to build a given component as stateless in its first iteration. Stateless components clearly expose what the interface to the component is — what messages can this component trigger and what state does it need? As that interface begins to emerge, we can evaluate if it seems fine to have different parents re-implement that interface or if that would pose too great a risk of bugs.

My rule of thumb is that, if at least two of these three criteria are met, then a stateful component might make sense:

  1. The component can trigger many messages.
  2. The component needs complex logic for its updates.
  3. The component is used in many places.

For AcceptTermsAndConditions, we’ve only got three messages, which is not a big deal. But we’ve seen that the update logic has enough complexity to allow inconsistency to sneak in. So it pretty much comes down to how many places the component is used, since every new use grows the risk of bugs.

These criteria don’t stand alone. As noted earlier, it also comes down to how many ancestors the component ends up having throughout the tree. If we use a stateful component right at the root of the page, the boilerplate is very manageable. But if the component is used deep in a component branch, and we have to adapt a long line of components to being stateful, we probably want to avoid it.

Coming back to AcceptTermsAndConditions, we’ll have to imagine its uses. It doesn’t seem like it will be used in that many places though, and not that deeply in the tree, so I would probably keep it stateless. I would then definitely write some function flipBooleans to call when receiving the onAcceptAllClicked message to keep behavior consistent. The risk of bugs is reasonably low this way, and the damage such a bug can cause is very limited in this particular case.

Examples of state handling

For more inspiration, here are some examples of how we’ve chosen our tradeoffs in different components in LogScale.

The first example is modal dialogs, which we often write as full fledged stateful components. Since they are typically isolated from the rest of the page anyway, giving them a separate module with their own logic and rendering usually works quite well. They can also easily end up with quite a few messages if they are moderately complex, making a stateful design even more natural. Modal dialogs are often also a good use case for child-to-parent communication, as you generally need the parent to know when the user has chosen to have the dialog closed. We like to use the OutMsg pattern in this case.

State handling has also turned out to be very important for form validation (unsurprisingly). Forms need dirty tracking, for example, whether the form has been submitted or not, and so on. But we also don’t want individual parts of the form to carry their own state (as we saw with Checkbox, for example). Our solution is to have a common FormState type (in a module of the same name) containing dirty tracking and more, along with its own update function to manage the state, which must be hooked up where the form is used.

The important part is that the FormState module is not responsible for rendering the form. Instead, we have all our existing stateless components (text fields, checkboxes, etc.) with functions to “mold” the FormState to be rendered by those components, and to have said components trigger FormState messages. This means our rendering code is still stateless and looks much like the rest of our rendering code, giving us the flexibility to compose form elements without a lot of boilerplate.

And finally, an example of component state which we have not implemented in Elm to keep our components “stateless”: the state needed for keyboard navigation in dropdown menus, for example. In this case, we need to track which elements are focused to be able to trigger a message when the user navigates down with the arrow key, for example.

To make this work in Elm, many of our core components would need to be stateful themselves, or pages would need to reimplement many messages to trigger navigation correctly. Both options would become unwieldy or leave too much room for inconsistency.

In the end, we chose instead to implement a web component to do the work and wrap the needed Elm components with that web component. This approach also saves us the work of trying to track focus in Elm, as our web component has full access to the DOM, which is already the definitive source of truth on which element has focus.

Looking beyond the component

So far, we’ve looked mainly at single components and how their design impacts themselves and their ancestors in the component tree. But the choice of stateful or stateless components also has implications beyond that; namely, also on how pages are structured in general.

The two key facts to remember here are:

  1. Stateless components are the more natural choice in Elm because stateful components can be very clunky to use.
  2. Stateless components push state to their parents.

The natural outcome is that more and more state is pushed out of components, conglomerating close to the root of the page. In turn, this actually makes it easier to build new features which need to interact with several pieces of state.

As an example, let’s say that our “Accept all” checkbox isn’t getting people to sign up for marketing emails as we’d hoped. We’ll remove it and try a chatbot popup. When a user checks the marketing checkbox, the bot might start singing or shooting fireworks.

Of course, we can’t assume that our AcceptTermsAndConditions component will be placed next to the bot component. They could easily be a ways down in different branches of the component tree. This means we might have to transport state from the AcceptTermsAndConditions component to some common ancestor, and then to the bot. But that’s only if the components are stateful. If AcceptTermsAndConditions and the bot component are stateless and their respective states already live close together, there’s less need for wiring code.

It’s our experience that while many components can look isolated when rendered on-screen, they can easily interact with states outside of their perceived boundaries. And using stateless components as the most common component type often means that our components more naturally fit into this reality.

By contrast, this is also a reason to be careful about using stateful components. Stateful components can feel alluring because their encapsulation is a way to set boundaries, which makes it easier to reason about the component in isolation. But we can easily create boundaries that are more of a hindrance than a help.

In summary

This post explored the benefits of stateless components, and how Elm pressures us towards using them over stateful components. When we lean into this, it leads us to write components which are easy to compose and apply in new ways, which is very nice. The main downside is that we can more easily introduce inconsistent behaviors across different uses of the same components.

When you’re coming from systems where a component is a well-defined concept, the pressure Elm applies can be very subtle. And for many developers, the instinct to create stateful components is still quite strong.

I hope this post has provided a bit of clarity. To summarize, here are a few rules of thumb:

  1. Prefer stateless components
  2. Let the page itself own as much of the state as possible
  3. Stateful components work best when used as close as possible to the component tree root

If you would like advice on creating more complex stateless components, I recommend watching this excellent presentation by Brian Hicks. At Falcon LogScale, we are quite happy with using the builder pattern, as explored in that video.

Thanks to my colleagues for their feedback on this post. It would have been a very different post without it!

References

  1. Elm is at version 0.19.1 at the time of writing. 
  2. Our two camps are very similar to “presentational” and “container” components or “dumb” and “smart” components, which you may know from React and Angular.
  3. This is also an example of “coloring,” though it is components which are forced to change “color” instead of functions.
Related Content