Upgrade your Elm Views: Part 2
Earlier this week I wrote a post on using selectors in Elm to make your views more readable. After posting that Luke Westby reached out and asked for a follow up on using selectors with Html.Lazy. I’m happy to do so.
If you’re unfamiliar with Html.Lazy it’s a pretty straightforward module that can offer great performance benefits in some situations. Let’s look at the type annotation for lazy.
lazy : (a -> Html msg) -> a -> Html msg
What lazy does is it takes a view function and the model you want to send to that view and it’ll check if that model is referentially equal to the last model you sent it. If it’s not the same reference it’ll recompute the Html otherwise it will return the cached result from last time.
With the selector from the previous post we can write out a more explicit type annotation:
lazy : (Model -> Html Msg) -> Model -> Html Msg
Or we can do this..
lazy : (ViewModel -> Html Msg) -> ViewModel -> Html Msg
There are trade-offs to both options that we’ll explore.
Before we do anything I want to make a slight modification to our existing view.
view : Model -> Html Msg
view =
todoItemsView << todoItemsSelector
..becomes..
composedItemsView : Model -> HtmlMsg
composedItemsView =
todoItemsView << todoItemsSelectorview : Model -> Html Msg
view =
composedItemsView
I do this to make it a little more explicit and to prevent future developers from moving this function composition into a let block (which would break memoization).
Aug. 2017 edit: Putting a lazy function in a let block won’t intrinsically break memoization. Rather, repeatedly redefining a lazy function will break memoization. By moving the composed lazy function to an argument-less composedItemsView function it’s a little more explicit that the composed lazy function will only be created once.
Now the question becomes, where do we put lazy?
Option 1: Make the composed function lazy
If we compose the new view function first then pass that to lazy it’d look something like this.
composedItemsView : Model -> HtmlMsg
composedItemsView =
lazy <| todoItemsView << todoItemsSelector
If it’s unclear what that’s doing we’re using function composition (<<) first to compose a new view function. That new function is passed as an argument to lazy, which returns a partially applied function that is still expecting a Model argument.
What this gets us is a child view that will only execute whenever the Model passed in changes. If the same Model is passed in twice, the cached result from last time is returned.
Option 2: Make the todoItemsView lazy, then compose it
With a couple of keystrokes we can change the precedence from the above example and change its meaning and performance.
composedItemsView : Model -> HtmlMsg
composedItemsView =
(lazy todoItemsView) << todoItemsSelector
Now we’re saying that we’re going to memoize the todoItemsView first, and then use function composition with todoItemsSelector. In effect, rather than checking whether the same Model was passed in, it’s checking whether the same ViewModel was passed in.
There’s a catch here. As I mentioned earlier Html.Lazy checks that the old and current model or view model are referentially equal. As you’ll find out with using selectors, they do a good job of creating new records and therefore new references.
If selectors return new references, then what’s the point of Option 2?
Selectors don’t have to return a new reference every time they are called. For example, you may have a selector that conditionally returns a default constant value.
-- TodoSelector.elmtype alias ViewModel =
{ filteredItems :
List
{ name : String
, isChecked : Bool }
, errorMessage : String
, submitButtonData :
{ text : String
, clickMsg : Msg }
}noItemsViewModel : ViewModel
noItemsViewModel =
{ filteredItems =[]
, errorMessage = "No TODOs!"
, submitButtonData =
{ text = "Update"
, clickMsg = NoOp }
}todoItemsSelector : Model -> ViewModel
todoItemsSelector model
if List.length model.todoItems == 0 then
noItemsViewModel
else
createViewModel model -- implementation not shown
Imagine a situation where Model has no todoItems but some other attribute on the model changes. With Option 1, the model changed reference so this particular view is recomputed, even though it’s the same view. With Option 2, the todoItemsSelector returns noItemsViewModel again, which is referentially equal to what it returned last time, and as a result the view is not recomputed again.
Option 3: What about memoized selectors?
Unfortunately, we can’t memoize our selector function. For reasons I haven’t been able to find, the only function memoization that you can do in Elm 0.18 is with functions that return Html msg and using the Html.Lazy module.
Until something in the language is exposed that allows you to memoize any arbitrary function, we’re constrained to only using Html.Lazy to memoize functions that return Html msg.
Takeaways
- Pull your composed functions out of your let blocks, especially if using Html.Lazy.
- Option 1 can give you performance benefits if the views are expensive to recompute.
- Option 2 can give you potentially more performance benefits with selectors that conditionally return constants, or slightly worse performance for selectors that always return referentially unique ViewModels. It’s really a judgement call in the end.
- Lastly, some important advice from the Html.Lazy readme, “[It] often makes things a lot faster, but definitely benchmark to be sure!”