Using the Builder Pattern for Elm Components

Jesse Warden - Mar 13 '22 - - Dev Community

I had the opportunity to build some components and quickly found many needed a lot of parameters. Below is a strategy on how to make them less verbose, type safe regardless of order, and it won’t cause existing code to break when you add new features.

Here's the first style I started with where you just pass them in as normal parameters.

-- label, click handler, isDisabled, icon
button "Click Me!" (Just Submit) False Nothing
Enter fullscreen mode Exit fullscreen mode

This causes 2 problems. First, you can easily forget what order parameters go in. Second, it’s verbose to use.

All parameters in the button function require different types so the compiler will help a little bit, but it’s painful to read. You could slightly improve with aliases in your type definition:

button : ButtonLabel -> Maybe ClickHandlerMessage -> IsDisabled -> Maybe Icon
Enter fullscreen mode Exit fullscreen mode

However, that still doesn’t affect problem #2 as you still are required to pass all parameters. If you’re not using disabled and icon, why must I be required to send to the component; can’t it default to reasonable values like enabling the button and no icon?

It’s easier to group all these into a Record since your function will then only take 1 parameter. Additionally, you don’t have the parameter order problem because the Record ensures it doesn’t matter what order you give the parameters.

type alias Config msg =
  { label : String
  , clickHandler : Maybe msg
  , isDisabled : Bool
  , icon : Maybe String } 

button { label = "Click Me", clickHandler = Just Submit, isDisabled = False, icon = Nothing }
Enter fullscreen mode Exit fullscreen mode

However, this creates 2 problems.

First, for optional parameters, you still have to define them. This makes using a component quite verbose; we have to create the record and set all of its properties. We don’t get the default values like we wanted.

Second, as soon as I add a new property in the future, I have to go and change all of my code. This last one is an architecture one and veers into YAGNI territory, meaning we could be doing premature design. Let’s evaluate the pro’s and con’s here.

Con: Why do it now if you can easily change your mind later? The Elm compiler is famous for it’s “fearless refactoring”; meaning not only do you not fear changing your code because the compiler is so good at finding problems and the error messages helpful, so much so that you get happy when you can refactor, and excited to do so. You have a new and/or good idea and want to try it, the compiler has your back.

Pro: I’m going to suggest we use the Builder Pattern to fix the button’s API for two reasons. First, it makes the component API easier to use right now vs. in the future. Second, if we add properties to the button as our design system evolves, you won’t have to change any existing code. Investing in a good API now will not only provide value now, but also in the future for any changes; double pay off.

Here’s an example of 4 uses of the button as it stands now.

button { label = "Submit", clickHandler = Just Submit, isDisabled = False, icon = Nothing }
, button { label = "Reports", clickHandler = Just GoToReports, isDisabled = False, icon = Nothing }
, button { label = "Settings", clickHandler = Nothing, isDisabled = True, icon = Nothing }
, button { label = "", clickHandler = Just GoToProfile, isDisabled = False, icon = Just "profile.svg" }
Enter fullscreen mode Exit fullscreen mode

Our designer wants more visual variety in our buttons as our application grows. She creates a text only version, and hints that possibly an outline version is coming, but is still designing it. We decide to create a button style class to dictate what type of button we’re creating; solid like above, the outline style (similar to Settings above, but with colors), and text only. The outline doesn’t exist yet, and that’s ok; we can just draw primary until she finishes the CSS.

type ButtonType = Solid | Outline | Text
Enter fullscreen mode Exit fullscreen mode

We update our Config record to include this new type:

type alias Config msg =
  { label : String
  , clickHandler : Maybe msg
  , isDisabled : Bool
  , icon : Maybe String
  , buttonType : ButtonType } 
Enter fullscreen mode Exit fullscreen mode

Good news and bad news: While the compiler let’s us know about all the buttons we have to update… we have to update all our uses of button 😢.

button { label = "Submit", clickHandler = Just Submit, isDisabled = False, icon = Nothing, buttonType = Solid }
, button { label = "Reports", clickHandler = Just GoToReports, isDisabled = False, icon = Nothing, buttonType = Solid }
, button { label = "Settings", clickHandler = Nothing, isDisabled = True, icon = Nothing, buttonType = Outline }
, button { label = "", clickHandler = Just GoToProfile, isDisabled = False, icon = Just "profile.svg", buttonType = Solid }
, button { label = "Logout", clickHandler = Just Logout, isDisabled = False, icon = Nothing, buttonType = Text }
Enter fullscreen mode Exit fullscreen mode

While our new text button looks good, more bad news: our record has made creating buttons even more verbose to create.

Let’s use the Builder Pattern and see how that API can solve our problems. We’ll only require a text label. For icon buttons that are just icons, we’ll assume for now the user will type in empty text; perhaps in the future we can reevaluate if we should create a separate IconButton component.

button "Click Me" config
Enter fullscreen mode Exit fullscreen mode

It may seem silly not to require a click handler, but sometimes in UI development you’re designing and not making it interactive; you just want to test some layouts. Let’s add a click handler now:

button "Click Me" (config |> setOnClick Just Submit)
Enter fullscreen mode Exit fullscreen mode

Notice that in both cases, it defaults to not being disabled, having no icon, and the style defaults to Solid. This implementation is hidden behind the components API. Let’s rewrite our original buttons and see if it makes it less verbose.

button "Submit" (config |> setOnClick (Just Submit))
, button "Submit" (config |> setOnClick (Just GoToReports))
, button "Settings" (config |> setDisabled True)
, button "" (config |> setOnClick (Just GoToProfile) |> setIcon (Just "profile.svg") )
, button "Logout" (config |> setOnClick (Just Logout))
Enter fullscreen mode Exit fullscreen mode

Nice, much less verbose! Now let’s compare it to when we add the new Button Type feature:

button "Submit" (config |> setOnClick (Just Submit))
, button "Submit" (config |> setOnClick (Just GoToReports))
, button "Settings" (config |> setDisabled True |> setType Outline)
, button "" (config |> setOnClick (Just GoToProfile) |> setIcon (Just "profile.svg") )
, button "Logout" (config |> setOnClick (Just Logout) |> setType Text)
Enter fullscreen mode Exit fullscreen mode

Notice only line 3 and 5 need to change; the rest still work. Imagine components not just all over your project, but in OTHER projects using your component library. This has the subtle, but powerful feature of allowing you to publish new features to your components and library without causing the existing API to break. Those who update to your library will not have to change any code.

In short, less verbose, no parameter order problems, and API additions do not break existing code.

Keep in mind for the pipes, many in Elm, whether they’re using elm-format or not, will break the pipes to a different line in case there are many of them. For some, they find this more readable (I’m in that group). Let’s show an example of that using the same pattern to design a Paginator, the row of numbered buttons that allow you to move between pages of data.


paginator
    (TotalPages 6)
    (config |> setSelectPage SelectPageNumber)
Enter fullscreen mode Exit fullscreen mode

This’ll give you the bare minimum to set the total number of pages, and a click handler when someone clicks on one of the number buttons. When we get a new feature of having previous page and next page buttons, OR if we only want to enable that feature when the user is listening for it, we can chain those too as well as setting what current page is selected by default:


paginator
    (TotalPages 86)
    (config |> setSelectPage SelectPageNumber
     |> setPreviousPage PreviousPage
     |> setNextPage NextPage
     |> setCurrentPage 46 )
Enter fullscreen mode Exit fullscreen mode

Any Downsides?

As you can see, for UI components that abstract basic HTML tags, the Builder Pattern is powerful in helping your API be easier to use, not having order problems, and preventing existing code from having to be changed if you add a feature.

Let’s talk about the downsides.

First, it’s unclear what the defaults are… because they’re abstracted away. We’re just “guessing” that a Button defaults to not having it’s disabled property set to true because “that’s how most buttons work”. Intuition is fine, but intuition can be wrong; that’s why we’re using types and a strict compiler. This forces people to read your source code and documentation to know what the defaults are.

Second, this creates a ton of setter functions in your component code to support this style for your customers. They’re not hard to write, but there is 1 for each setting, and more feature full components will have at least 1 setter for each exposed feature.

API Implementation

We’ve talked about how consuming the API looks like, now let’s look at how you’d write it. We’ll take our existing record and types from above first:

type ButtonType = Solid | Outline | Text

type alias Config msg =
  { label : String
  , clickHandler : Maybe msg
  , isDisabled : Bool
  , icon : Maybe String
  , buttonType : ButtonType }
Enter fullscreen mode Exit fullscreen mode

Those are in your Button.elm module, but you do NOT need to expose them if you don’t want to as people can just use your Module’s name like Button.ButtonType. Your call.

However, before we build our component, let’s setup some defaults so users don’t have to manually fill in them. We’ll create a config function that returns a default config:

config =
  { label = "", clickHandler = Nothing, isDisabled = False, icon = Nothing, buttonType = Solid }
Enter fullscreen mode Exit fullscreen mode

Our type for it is just a Config, but that Config contains messages supplied by the user. We don’t know what these are, so we’ll give them a type parameter for it just called msg:

config : Config msg
config =
  { label = "", clickHandler = Nothing, isDisabled = False, icon = Nothing, buttonType = Solid }
Enter fullscreen mode Exit fullscreen mode

Our button component needs 2 parameters: a String label and a Config, and it needs to return your button:

button label config_ =
    Html.button [...][...]
Enter fullscreen mode Exit fullscreen mode

Our label is straightforward but our Config has a parameter of msg; we don’t know what type of Click Handler the user will pass, so we just make it a type parameter so they can pass whatever they want, and in turn, our Config record will get it as a parameter too:

button : String -> Config msg -> Html msg
button label config_ =
    Html.button [...][...]
Enter fullscreen mode Exit fullscreen mode

The guts can get complex, but there are some tricks I’ve learned that I’ll share, let’s come back to this later.

For the setters, they need to take in 2 things: a value, a config. They then need to return a Config so they can be chained with other setters.

setOnClick maybeMessage config_ =
  { config_ | clickHandler = maybeMessage }
Enter fullscreen mode Exit fullscreen mode

The type is our click handler message that may or may not be there, the config you’d like to modify, and the return value is the newly updated config. Note we are continually use config_ with the underscore suffix to make it clear this is supplied by the user or for the function, it has NOTHING to do with the config function.

setOnClick : Maybe msg -> Config msg -> Config msg
setOnClick maybeMessage config_ =
  { config_ | clickHandler = maybeMessage }
Enter fullscreen mode Exit fullscreen mode

All of our setter functions make use of function currying to have the last parameter out of the pipes always be a Config msg.

Loop Trick

I learned this one from Alexander Foremny’s Material Design Web Components in Elm. When you’re attempting to apply a bunch of optional parameters, Maybe’s can start to become quite a pain. Things like Maybe.map and Maybe.andThen can help, sure, but typically you want a list of attributes that you can give to your HTML component without a lot of code and list merging. Worse, though, is when you compare to values that aren’t a Maybe; then you’re switching back and forth between the various types while trying to have a nice, clean looking piped code.

There is a function called filterMap that is super useful in reducing how much code you need to write checking if something is a Just or Nothing. It’s like a map in that it’ll run your function, but the filter part automatically filters out all the Nothings if you use it with identity. This is great because if you have a button like this:

Html.button [] []
Enter fullscreen mode Exit fullscreen mode

You’re interested in that first list to contain the disabled property if needed, the click handler if needed, and the buttonType styles that are appropriate for that button style.

The naive way would be to make a bunch of getters that return the good stuff, else an empty List:

getDisabled config_ =
  if config_.isDisabled == True then
    [ Html.Attributes.disabled True ]
  else
    []

getClickHandler config_ =
  case config_.clickHandler of
    Nothing -> []
    Just handler -> [ Html.Events.onClick handler ]

getStyle config_ =
  case config_.buttonStyle of
    Text -> [ Html.Attributes.class "button-text-style" ]
    _ -> [ Html.Attributes.class "button-regular-style" ]
Enter fullscreen mode Exit fullscreen mode

Then you’d wire ’em together like:

Html.button
  (
    (getDisabled config_)
    ++ (getClickHandler config_)
    ++ (getStyle config_)
  )
  []
Enter fullscreen mode Exit fullscreen mode

Gross. Instead, you create wrappers around your values to return Maybes so they too can be chained. Like the setters, this requires more work, but your component code ends up much more readable.

First, you need to change your getters to maybes:

getDisabled config_ =
  Just (Html.Attributes.disabled config_.disabled)

getClickHandler config_ =
  Maybe.map Html.Events.onClick config_.clickHandler

getStyle config_ =
  case config_.buttonStyle of
    Text -> Just ( Html.Attributes.class "button-text-style" )
    _ -> Just ( Html.Attributes.class "button-regular-style" )
Enter fullscreen mode Exit fullscreen mode

Then, your component code becomes:

Html.button
  List.filterMap identity
    [ getDisabled config_
    , getClickHandler config_
    , getStyle config_ ]
  []
Enter fullscreen mode Exit fullscreen mode

You can do the same style with the button contents as well.

Conclusions

For simple components, or those just wrapping standard HTML tags with styles, going from primitives to type aliases to Records is a great step in making your API more type safe, having better compiler errors, and making it easier on yourself to support it as more people and teams use your components. It also makes it easier on your consumers to use your components.

However, as the Records increase in size, you make it more verbose for yourself and your users to create the components. If you add features, all the code they wrote has to change, even if they’re not using the new feature. Additionally, there becomes this negative expectation that “all cool new features come with this uncool updating our code even if we aren’t using the new feature”. Using the Builder Pattern can really help solve those issues with little trade offs. You ensure they only use what they need, parameter order isn’t a problem just like records, and they don’t need to update their code when new features are released.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .