Audit all Html.Lazy usage in the codebase and report any patterns that silently break lazy evaluation, along with fixes.
Use when asked to:
- "audit lazy usage"
- "check Html.Lazy"
- "are we using lazy correctly"
- "review elm lazy"
- "fix lazy performance"
Html.Lazy works by creating a thunk node in the virtual DOM that stores the function reference and its arguments. During diffing, the Elm runtime checks whether those values are equal to the previous render using JavaScript's ===. If all are equal, the cached output is reused — the view function never runs.
Equality rules (from Elm runtime):
Int,Float,String,Char,Bool→ structural equality (compared by value)- Records, Lists, Custom Types, Dicts, Tuples → reference equality (
===on memory address)
This means: even if two records have the same field values, lazy will miss the cache if they are not the same object in memory.
Other caching properties to know:
- The cache only remembers the last render — there is no history. Oscillating between two states (e.g., tab A → tab B → tab A) will not hit the cache on the return trip.
- If a lazy node is removed from the view (e.g., conditional rendering) and later re-added, the cache is gone and the thunk recomputes.
- Two separate uses of the same lazy call do not share a cache — each thunk is independent.
- Cache misses degrade gracefully (you just pay for a normal render), but persistent misses mean the lazy call provides zero benefit and adds a small overhead.
This codebase enforces the UseMemoizedLazyLambda elm-review rule. All lazy calls must be at the top level of a point-free function definition with a lambda as the first argument. This is the only compliant form.
-- ✅ REQUIRED PATTERN: point-free definition, lazy at top level, lambda as first arg
viewSidebar : SidebarData -> Html Msg
viewSidebar = lazy (\data -> renderSidebar data)
-- ✅ For lazy2:
viewItem : Config -> Item -> Html Msg
viewItem = lazy2 (\config item -> renderItem config item)
-- Then call it normally — NO lazy at the call site:
view model =
viewSidebar model.sidebarData
viewItem config itemWhy this pattern?
lazyis partially applied at the definition site (no value args yet), so the function reference stored in the thunk is stable across renders.- The lambda ensures the inner function is called with a fresh application each time, but the thunk wrapper itself is the stable reference Elm checks.
- Any inline
lazy ...call inside a view function body — evenlazy viewSidebar model— is flagged byUseMemoizedLazyLambda.
How UseMemoizedLazyLambda and NoLazyEvaluationBreakingPatterns coexist:
NoLazyEvaluationBreakingPatterns Bug 1 only fires on fully applied lazy calls (where value args are present inline). In the memoized pattern, lazy is partially applied (no value args at the definition site), so Bug 1 is not triggered. The two rules are fully compatible.
Any lazy call that appears inside a view function body is a violation of UseMemoizedLazyLambda, regardless of whether the function argument is named or a lambda.
-- ❌ BAD: lazy called inline — flagged by UseMemoizedLazyLambda
view model =
lazy viewSidebar model.sidebar
-- ❌ ALSO BAD: inline with lambda wrapper
view model =
lazy (\m -> viewSidebar m) model.sidebar
-- ✅ GOOD: point-free definition at top level with lambda as first arg
viewSidebar : SidebarData -> Html Msg
viewSidebar = lazy (\data -> renderSidebar data)
-- Call site uses the wrapper, not lazy directly:
view model =
viewSidebar model.sidebar-- BAD: a new record object is allocated on every render → cache always misses
viewItem = lazy2 (\config item -> renderItem config item)
-- Called with inline record:
view model =
viewItem config { id = item.id, name = item.name } -- ❌ new record each render
-- GOOD: pass the record that already lives in the model
view model =
viewItem config item-- BAD: new list reference on every render
view model =
viewList [ item1, item2, item3 ] -- ❌ new list each render
-- GOOD: pass the list from the model directly
view model =
viewList model.items-- BAD: List.filter produces a new list reference every render
view model =
viewList (List.filter isActive model.items) -- ❌ new list each render
-- GOOD option A: store the derived list in the model and update it in update
view model =
viewList model.activeItems
-- GOOD option B: memoized point-free form passing raw stable inputs separately
viewList : List Item -> Bool -> Html Msg
viewList = lazy2 (\items filterActive -> renderList items filterActive)
-- Call site — no lazy here:
view model =
viewList model.items model.filterActive-- SUSPICIOUS: if the model changes on nearly every message, lazy adds overhead with no benefit
viewPage : PageModel -> Html Msg
viewPage = lazy (\model -> renderPage model)
-- Called with entire model:
view model =
viewPage model -- ❌ cache misses on every model change
-- BETTER: pass only the sub-fields the view actually reads, and that change infrequently
viewPage : SidebarData -> Theme -> Html Msg
viewPage = lazy2 (\sidebar theme -> renderPage sidebar theme)
view model =
viewPage model.sidebar model.theme-- WATCH OUT: if this node is hidden/shown based on a flag, each time it reappears
-- the cache is cold and the thunk recomputes. Lazy won't help here.
if model.showPanel then
viewPanel model.panelData
else
Html.noneThis isn't always a bug — lazy still avoids unnecessary re-renders while the node is present. But if the node is toggled frequently, be aware the cache is lost on each hide.
-
Find all lazy usage — search for
Html.Lazy.lazy,lazy,lazy2,lazy3,lazy4,lazy5,lazy6,lazy7,lazy8across all.elmfiles. Note the import style (qualified vs unqualified). -
For each call site, inspect:
- Is the lazy call at the top level of a point-free function definition with a lambda as the first arg? (Good) Or is it inline anywhere in a view function body? (Violation of
UseMemoizedLazyLambda— Bug 1) - Does the lambda have full arity matching the lazy variant (1 arg for
lazy, 2 forlazy2, etc.)? (Good) Otherwise arity mismatch. - Are value arguments at the call site constructed inline — records
{ ... }, lists[ ... ], tuples( ... )? (Bugs 2–3) - Are value arguments the result of a function call that produces a new allocation? (Bug 4)
- Is the argument scope unnecessarily wide (e.g., the whole model)? (Bug 5)
- Is the node conditionally rendered and therefore loses its cache on re-appearance? (Bug 6 — note, don't flag this as a hard error, just a callout)
- Is the lazy call at the top level of a point-free function definition with a lambda as the first arg? (Good) Or is it inline anywhere in a view function body? (Violation of
-
Report findings grouped by file with:
- File path and line number
- The problematic pattern quoted from the code
- Why it breaks (or weakens) lazy
- The recommended fix
-
If asked to fix, apply fixes directly. Prefer:
- Extracting inline lazy calls into new top-level point-free functions with
lazy (\arg1 ... argN -> ...)and a full-arity lambda - Adding a type annotation on the new point-free function (required by
UseMemoizedLazyLambda) - Replacing inline constructions with stable references from the model at the call site
- For derived values: suggest storing in the model, or decompose into
lazy2/lazy3with raw stable inputs in the point-free definition
- Extracting inline lazy calls into new top-level point-free functions with
## Html.Lazy Audit
### Issues Found
**src/Page/Dashboard.elm:142** — Inline lazy call (UseMemoizedLazyLambda violation)
`lazy viewHeader model.header`
lazy is called inline inside a view function. All lazy calls must be at the top level
of a point-free function definition.
Fix:
viewHeader : HeaderData -> Html Msg
viewHeader = lazy (\data -> renderHeader data)
Then at call site: `viewHeader model.header`
**src/Page/Dashboard.elm:155** — Anonymous function wrapper (inline)
`lazy (\m -> viewSidebar m) model.sidebar`
lazy is called inline with a lambda wrapper — both an inline call violation and
a new lambda allocated on every render.
Fix: same as above — extract to a point-free top-level definition.
**src/Components/List.elm:67** — Inline record construction at call site
`viewItem config { id = item.id, label = item.label }`
A new record object is created on every render. Reference equality will always fail.
Fix: Pass `item` directly if it has the right shape, or store the config record in the model.
### Observations (not bugs, but worth knowing)
**src/Page/Settings.elm:201** — Conditionally rendered lazy node
`if model.showAdvanced then viewAdvanced model.advancedData else Html.none`
Lazy won't cache across toggles — each time the node reappears, the cache is cold.
This is fine if the panel is rarely toggled, but lazy provides no help on re-open.
### Looks Good
- src/Page/Home.elm:88 — `viewSidebar : SidebarData -> Html Msg` / `viewSidebar = lazy (\data -> ...)` ✓
- src/Components/Nav.elm:34 — `viewNav : Theme -> List NavItem -> Html Msg` / `viewNav = lazy2 (\theme items -> ...)` ✓
---
### Summary
X issues found across Y files. Z call sites look correct.