Guides

Lifecycle

Override a component's lifecycle methods to run code when it joins a page, each time it updates and when it leaves. Lit calls them; this guide covers which one to use for what: DOM access, subscriptions, cleanup, and changes Lit can't see for itself.

Most components only override render. The other lifecycle methods are for work a template can't do: listening to the window, starting a timer, focusing an input, handing an element to a JavaScript library. Each is a member of LitElement, so you override it in your class, and the compiler checks its signature.

Watch the lifecycle

The Count button is a component that logs every lifecycle method it overrides; the rest is the demo around it. The log starts with what ran as the page loaded. Click the count and read the cycle that follows. Tick Hold updates and click the count again: shouldUpdate returns false, and the button keeps its old count. Untick it, then remove the component and put it back.

module Snippets.LifecycleLog

open Fable.Core
open Browser.Types
open Firelight
open type Firelight.Lit

let private names (changed: System.Collections.Generic.Dictionary<string, obj>) =
    String.concat ", " changed.Keys

/// The component under study. Each lifecycle method it overrides writes a line to the log.
[<AttachMembers>]
type LifecycleProbe() =
    inherit LitElement()

    // Lines written before the demo has said where to write them.
    let early = ResizeArray<string>()
    let mutable write: string -> unit = early.Add
    let log line = write line

    do log "constructor"

    static member properties =
        PropertyDeclarations.create [
            "count", PropertyDeclaration<int>(state = true)
            "held", PropertyDeclaration<bool>()
        ]

    static member styles = css $$"""button { font: inherit; padding: 0.3rem 0.8rem; }"""

    member val count = 0 with get, set
    member val held = false with get, set

    /// Where to write the log. A plain property: setting it doesn't need an update.
    member _.logTo
        with set (f: string -> unit) =
            write <- f
            early |> Seq.iter f
            early.Clear()

    override this.connectedCallback() =
        base.connectedCallback ()
        log "connectedCallback"

    override this.disconnectedCallback() =
        base.disconnectedCallback ()
        log "disconnectedCallback"

    override this.shouldUpdate(changed) =
        log $"shouldUpdate ({names changed}): {not this.held}"
        not this.held

    override this.willUpdate(changed) = log $"willUpdate ({names changed})"

    override this.update(changed) =
        log "update"
        base.update changed

    override this.firstUpdated(_) = log "firstUpdated"
    override this.updated(changed) = log $"updated ({names changed})"

    member this.AddOne() =
        this.count <- this.count + 1

        promise {
            let! _ = this.updateComplete
            log "updateComplete resolved"
        }
        |> Promise.start

    override this.render() =
        log "render"
        html $"""<button @click={fun _ -> this.AddOne()}>Count: {this.count}</button>"""

/// The controls and the log.
[<AttachMembers>]
type LifecycleDemo() as this =
    inherit LitElement()

    let logList = createRef<HTMLElement> ()

    // The probe writes during updates, its own and this one's (it disconnects while this renders),
    // so each line is added after the current update.
    let write (line: string) =
        JS.setTimeout (fun () -> this.lines <- this.lines @ [ line ]) 0 |> ignore

    static member properties =
        PropertyDeclarations.create [
            "lines", PropertyDeclaration<string list>(state = true)
            "shown", PropertyDeclaration<bool>(state = true)
            "held", PropertyDeclaration<bool>(state = true)
        ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.75rem; width: min(100%, 26rem); }
        .controls { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: center; }
        button { font: inherit; padding: 0.3rem 0.8rem; }
        ol {
            margin: 0; padding: 0.5rem 0.5rem 0.5rem 2.5rem; height: 14rem; overflow: auto;
            font: 0.85rem var(--mono); background: var(--bg); border: 1px solid var(--border);
            border-radius: var(--radius);
        }
        """

    member val lines: string list = [] with get, set
    member val shown = true with get, set
    member val held = false with get, set

    // DOM work after an update: keep the newest line in view.
    override this.updated(changed) =
        if changed.ContainsKey "lines" then
            logList.value |> Option.iter (fun ol -> ol.scrollTop <- ol.scrollHeight)

    override this.render() =
        let probe =
            if this.shown then
                html $"""<my-lifecycle-probe .logTo={write} .held={this.held}></my-lifecycle-probe>"""
            else
                nothing

        html
            $"""
        <div class="controls">
            {probe}
            <label>
                <input type="checkbox" ?checked={this.held} @change={Ev.checked' (fun on -> this.held <- on)}>
                Hold updates
            </label>
        </div>
        <div class="controls">
            <button @click={fun _ -> this.shown <- not this.shown}>
                {if this.shown then "Remove it" else "Put it back"}
            </button>
            <button @click={fun _ -> this.lines <- []}>Clear the log</button>
        </div>
        <ol {ref logList} aria-label="Lifecycle log">{this.lines |> List.map (fun line -> html $"<li>{line}</li>")}</ol>"""

defineElement<LifecycleProbe> "my-lifecycle-probe"
defineElement<LifecycleDemo> "my-lifecycle-demo"

Putting the component back logs constructor again. The template creates a new element, so nothing carries over from the old one. Lit SSR also ran the constructor, willUpdate and render when this page was built, to prerender the demo; the log only shows what ran in your browser. See Prerendering components at build time.

Connected and disconnected

connectedCallback runs when the element joins a document, and disconnectedCallback when it leaves. Set up anything outside the component there, such as a listener on window or a timer, and undo it on the way out. An element can be removed and added again, when a list moves it for example, so set up in connectedCallback, not in the constructor.

open Browser
open Browser.Types
open Fable.Core
open Firelight
open type Firelight.Lit

[<AttachMembers>]
type LastKey() =
    inherit LitElement()

    let mutable stopListening = ignore

    static member properties =
        PropertyDeclarations.create [ "key", PropertyDeclaration<string>(state = true) ]

    member val key = "none yet" with get, set

    override this.connectedCallback() =
        base.connectedCallback ()
        stopListening <- Ev.listen window "keydown" (Ev.keyboard (fun e -> this.key <- e.key))

    override this.disconnectedCallback() =
        base.disconnectedCallback ()
        stopListening ()

    override this.render() =
        html $"""<p>Last key pressed: {this.key}</p>"""

Call the base method first in both. Lit's connectedCallback creates the shadow root and lets updates start, so a component that skips it never renders. Lit's disconnectedCallback tells the component's controllers it has gone.

Ev.listen adds the listener and returns the function that removes it, so the cleanup stays next to the setup. Removing a listener yourself is harder than it looks in F#: removeEventListener only removes the very function addEventListener was given, and Fable compiles a method such as this.OnKeyDown, and a function bound with let in the class, to a member, so passing either one as a value wraps it in a new JavaScript function each time. Removing it then removes nothing, and the window keeps the element alive. The Events guide has more.

The update cycle

An update starts with a request: assigning a reactive property, or calling requestUpdate. Lit waits until the current code has finished, then runs one update for every change made so far, so three assignments in one click handler make one update. These are the methods it calls, in order:

Method Use it to Runs on the server
shouldUpdate changed Skip this update, by returning false No
willUpdate changed Compute values from properties before rendering Yes
update changed Render; override only to wrap it, and call the base No
render () Return the template Yes
firstUpdated changed Do one-time work on the rendered DOM No
updated changed Do DOM work after each change No

After updated, the element's updateComplete promise resolves. The last column matters only when the component is prerendered.

changed is a Dictionary<string, obj>: Lit's changedProperties, a JavaScript Map at runtime. Its keys are the names of the properties that changed, and its values their old values, as obj. Test for a key with changed.ContainsKey "count", and unbox an old value with unbox<int> changed["count"]. On an element's first update in the browser, every reactive property with a value is in it. A property that changed while shouldUpdate returned false is not in the next update's map, as the demo shows when you untick Hold updates.

Compute values in willUpdate

willUpdate is the place for values derived from properties, such as a sorted list. Assigning a property there joins the current update instead of starting another:

open Fable.Core
open Firelight
open type Firelight.Lit

[<AttachMembers>]
type Leaderboard() =
    inherit LitElement()

    let mutable ranked: (string * int) list = []

    static member properties =
        PropertyDeclarations.create [ "scores", PropertyDeclaration<Map<string, int>>() ]

    member val scores: Map<string, int> = Map.empty with get, set

    override this.willUpdate(changed) =
        if not this.hasUpdated || changed.ContainsKey "scores" then
            ranked <- this.scores |> Map.toList |> List.sortByDescending snd

    override this.render() =
        html $"""<ol>{ranked |> List.map (fun (name, score) -> html $"<li>{name}: {score}</li>")}</ol>"""

The not this.hasUpdated covers the first render on the server, where changed leaves out a property that still has the value its member val gave it. Assigning a property in updated instead works, but costs a second update every time, and Lit's development build warns: "scheduled an update … after an update completed".

Reach the DOM after it renders

Before the first update there is no rendered DOM, so the constructor and render can't use it, and on the server they run without a DOM at all. Use firstUpdated for one-time work, such as handing an element to a JavaScript library, and updated for work that follows each change. Reach elements with a ref rather than a query.

When code changes a property and then needs the result in the DOM, await updateComplete, with let! in Fable.Promise's promise { }. This panel focuses its search box once the box is there:

open Browser.Types
open Fable.Core
open Firelight
open type Firelight.Lit

[<AttachMembers>]
type SearchPanel() =
    inherit LitElement()

    let field = createRef<HTMLInputElement> ()

    static member properties =
        PropertyDeclarations.create [ "isOpen", PropertyDeclaration<bool>(state = true) ]

    member val isOpen = false with get, set

    member this.Open() =
        this.isOpen <- true

        promise {
            let! _ = this.updateComplete
            field.value |> Option.iter (fun input -> input.focus ())
        }
        |> Promise.start

    override this.render() =
        if this.isOpen then
            html $"""<input type="search" {ref field}>"""
        else
            html $"""<button @click={fun _ -> this.Open()}>Search</button>"""

updateComplete waits for this element's update only, not for components inside it, and it resolves even when shouldUpdate skipped the render.

When Lit can't see a change

Lit starts an update when a reactive property is assigned a different value. It can't see a let mutable field change, a value change in place (see the Templates guide), or an object that a JavaScript library updates. After any of these, call this.requestUpdate (). It's the same request a property assignment makes, and requests made before the update runs share it. The Elmish and Task controllers call it for you when their state changes.

Prefer reactive properties for state the component renders, with state = true for private state. Use a let mutable field and requestUpdate for state that isn't a single value, or that other code changes.

Lit compares a property's old and new values by identity. An F# record, list or map with equal contents is still a new value, so assigning one renders again. That's usually harmless; when it isn't, give the property F#'s equality:

open Fable.Core
open Firelight
open type Firelight.Lit

type Point = { X: float; Y: float }

[<AttachMembers>]
type Marker() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [
            "point",
            PropertyDeclaration<Point>(hasChanged = PropertyDeclaration.HasChanged(fun value old -> value <> old))
        ]

    member val point = { X = 0.0; Y = 0.0 } with get, set

    override this.render() =
        html $"""<p>{this.point.X}, {this.point.Y}</p>"""

Where things go

You need to Put it in
Set initial values, create controllers The constructor: let and member val
Listen to window or document, start a timer connectedCallback, undone in disconnectedCallback
Derive values from properties willUpdate
Focus, measure, start a JavaScript library firstUpdated
Change the DOM after a property changes updated
Wait for the DOM after setting a property updateComplete
Load data Firelight.Task
Reuse any of these across components A controller

Common mistakes

The compiler catches some lifecycle mistakes:

You wrote The compiler says Write instead
member this.connectedCallback() = ... Warning: This new member hides the abstract member 'abstract LitElement.connectedCallback: unit -> unit'. Rename the member or use 'override' instead override, and call the base
override this.firstUpdated() = ... This override takes a different number of arguments to the corresponding abstract member override this.firstUpdated(_) = ...
override this.updated(changed: Map<string, obj>) This expression was expected to have type 'Dictionary<string,obj>' but here has type 'Map<string,obj>' Leave the type off: override this.updated(changed)
changed["count"] + 1 The type 'int' does not match the type 'obj' unbox<int> changed["count"] + 1

The first row is only a warning, so the build carries on unless your project treats warnings as errors. The hiding member replaces Lit's connectedCallback on the element, so the component never renders.

Others compile:

You wrote What happens Write instead
An override of connectedCallback or update without the base call Nothing renders base.connectedCallback (), base.update changed
An override of disconnectedCallback without the base call Controllers keep running after the element has gone base.disconnectedCallback ()
window.removeEventListener ("resize", this.OnResize) Removes nothing Ev.listen, as LastKey does
window.addEventListener in the constructor Never removed; prerendering fails with "window is not defined" connectedCallback
this.shadowRoot.querySelector in the constructor or render Throws or finds nothing: before the first update, the shadow root is missing or empty A ref, read in firstUpdated or later
this.total <- ... in updated A second update after every update Compute it in willUpdate