Guides

Properties and attributes

Declare a component's inputs as reactive properties in F#. Lit re-renders the component when one changes, and links each one to an HTML attribute, so the same component can be set up from HTML or from an F# template. This guide covers the declarations and how they meet F# types.

A reactive property is a member that Lit watches. Set it, from F#, from JavaScript or through an attribute, and Lit schedules a render with the new value. The render runs after the current code finishes, so setting several properties at once causes one render. Until then the DOM shows the old values; to read it after a change, wait for the component's updateComplete promise.

Declare a property

A property has two halves: a member val that holds the value and its default, and an entry in properties that tells Lit to watch it.

open Fable.Core
open Firelight
open type Firelight.Lit

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

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

    member val name = "World" with get, set

    override this.render() = html $"""<p>Hello, {this.name}.</p>"""

defineElement<Greeting> "my-greeting"

<my-greeting name="Ada"></my-greeting> says "Hello, Ada." in a page, and so does setting greeting.name <- "Ada" from F#.

Fable compiles member val to a getter and a setter on the JavaScript class. [<AttachMembers>] puts them there, and Lit wraps them with its own setter, which asks for an update. The string in properties is how Lit finds the member, so it must match the member's name exactly, case included. The compiler doesn't check it. To have it checked, write nameof Unchecked.defaultof<Greeting>.name in place of "name".

The type argument of PropertyDeclaration<'T> is the member's type. It types the hasChanged and converter options, and it decides how Lit reads the attribute, as the next section shows. PropertyDeclaration<'T> comes with open type Firelight.Lit, like html. Its options:

Option What it does Example
attribute Names the attribute, or turns it off attribute = "warn-at", attribute = false
reflect Copies the property back to its attribute reflect = true
useDefault Keeps the default out of the attribute and restores it when the attribute is removed useDefault = true
state Marks internal state, with no attribute state = true
hasChanged Decides what counts as a change hasChanged = fun next prev -> next <> prev
converter Converts the attribute to and from the property converter = AttributeConverter(fromAttribute = parse)
``type`` Replaces the conversion chosen from 'T ``type`` = jsConstructor<Globals.Array>
noAccessor Leaves the setter to you; call requestUpdate yourself noAccessor = true

type is an F# keyword, so that option is written in double backticks.

Attributes are text

HTML can only set attributes, and an attribute's value is always text. A property can hold any value. Lit links each declared property to an attribute named after it in lower case (maxItems to maxitems), and converts the attribute's text by the property's type:

'T step="0.5" becomes When the attribute is removed
float, int and the other numeric types 0.5 null
bool true: any value, even "false" false
string, and any other type "0.5", a string null

PropertyDeclaration<'T> sets Lit's type option to Number for the types Fable compiles to JavaScript numbers (float, float32, int, int16, uint16, uint32, sbyte and byte) and to Boolean for bool. Any other type gets the text as it is, whatever its F# type says. int64 and decimal are numbers in F# but not JavaScript numbers, and a list, an option or a record has no text form. Give those a converter, or turn the attribute off.

Here step is a float, so step="0.5" arrives as the number 0.5. The tags are a string list, with a converter that splits tags="lit, fable, fsharp" into a list, and joins the list back into the attribute, which it reflects. Click both buttons, and watch the second element's tags attribute in your browser's developer tools:

module Snippets.PropertyTypes

open Fable.Core
open Firelight
open type Firelight.Lit

let private styles =
    css
        $$"""
    :host { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem; }
    button { font: inherit; padding: 0.2rem 0.6rem; }
    .tag { padding: 0.1rem 0.6rem; border-radius: 1rem; background: var(--code-bg, #eee); }
    """

/// <my-quantity step="0.5"></my-quantity>
[<AttachMembers>]
type Quantity() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [
            // A float, so Lit converts the attribute's text to a number.
            "step", PropertyDeclaration<float>()
            "total", PropertyDeclaration<float>(state = true)
        ]

    static member styles = styles

    member val step = 1.0 with get, set
    member val total = 0.0 with get, set

    override this.render() =
        html
            $"""
        <button @click={fun _ -> this.total <- this.total + this.step}>Add {this.step}</button>
        <span>Total: <output>{this.total}</output></span>"""

/// Splits "a, b, c" into a list, and joins it back when the property is reflected.
let private commaSeparated =
    AttributeConverter<string list>(
        fromAttribute =
            (fun text ->
                match text with
                | Some text -> text.Split ',' |> Array.map _.Trim() |> Array.filter ((<>) "") |> List.ofArray
                | None -> []),
        toAttribute = fun tags -> Some(String.concat ", " tags)
    )

let private tagChip (tag: string) = html $"""<span class="tag">{tag}</span>"""

/// <my-tags tags="lit, fable, fsharp"></my-tags>
[<AttachMembers>]
type Tags() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [ "tags", PropertyDeclaration<string list>(converter = commaSeparated, reflect = true) ]

    static member styles = styles

    member val tags: string list = [] with get, set

    member this.AddTag() =
        this.tags <- this.tags @ [ $"tag{this.tags.Length + 1}" ]

    override this.render() =
        html
            $"""
        {this.tags |> List.map tagChip}
        <button @click={fun _ -> this.AddTag()}>Add a tag</button>"""

defineElement<Quantity> "my-quantity"
defineElement<Tags> "my-tags"

fromAttribute gets the attribute's text as a string option, None once the attribute is removed. toAttribute returns the text to write, or None to remove the attribute; Lit calls it only for a property with reflect = true. Leave either out to keep Lit's conversion for that direction.

A Boolean attribute works like HTML's own disabled: present means true, absent means false. open="false" is still present, so it's true. Give a Boolean property a false default, since an attribute can't turn a true default off. From a template, bind it with ?open={isOpen}, which adds or removes the attribute.

An attribute the page leaves out does nothing, and the property keeps its F# default. Removing one later sets the property, as in the table's last column, whatever its F# type says. An int turns null into 0; a string or a float stays null. useDefault = true restores the default instead.

For a different attribute name, such as warn-at for warnAt, set attribute = "warn-at". For a value that can't be text, such as a list, a record or a function, set attribute = false, and pass it as a property instead.

Passing F# values with .prop

A component used in another component's template takes values through properties. .players={…} sets the property itself, with no text in between, so any F# value arrives as it is. The Templates guide lists the other bindings.

This scoreboard keeps a list of player records and passes it, sorted, to two rankings with .players={ranked}. Click the +1 buttons, then type in Note:

module Snippets.PropertyObjects

open Fable.Core
open Fable.Core.JsInterop
open Firelight
open type Firelight.Lit

type Player = { Name: string; Score: int }

let private rankingStyles =
    css
        $$"""
    :host { display: block; padding: 0.5rem 0.75rem; border: 1px solid var(--border); border-radius: 0.5rem; }
    h3 { margin: 0 0 0.25rem; font-size: 1rem; }
    ol { margin: 0; padding-inline-start: 1.25rem; }
    p { margin: 0.25rem 0 0; color: var(--muted); }
    """

let private ranking (title: string) (players: Player list) (renders: int) =
    html
        $"""
    <h3>{title}</h3>
    <ol>{players |> List.map (fun p -> html $"<li>{p.Name}: {p.Score}</li>")}</ol>
    <p>Renders: {renders}</p>"""

/// <my-ranking .players={players}>: Lit's default check, by identity.
[<AttachMembers>]
type Ranking() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [ "players", PropertyDeclaration<Player list>(attribute = false) ]

    static member styles = rankingStyles

    member val players: Player list = [] with get, set
    member val renders = 0 with get, set

    override this.render() =
        this.renders <- this.renders + 1
        ranking "By identity" this.players this.renders

/// The same, but a list equal to the last one isn't a change.
[<AttachMembers>]
type EqualityRanking() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [
            "players", PropertyDeclaration<Player list>(attribute = false, hasChanged = fun next prev -> next <> prev)
        ]

    static member styles = rankingStyles

    member val players: Player list = [] with get, set
    member val renders = 0 with get, set

    override this.render() =
        this.renders <- this.renders + 1
        ranking "By equality" this.players this.renders

/// Owns the scores and passes them down with `.players`.
[<AttachMembers>]
type Scoreboard() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [
            "players", PropertyDeclaration<Player list>(state = true)
            "note", PropertyDeclaration<string>(state = true)
        ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.75rem; }
        .controls { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
        .boards { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 0.75rem; }
        button, input { font: inherit; padding: 0.2rem 0.6rem; }
        """

    member val players = [ { Name = "Ana"; Score = 2 }; { Name = "Ben"; Score = 1 } ] with get, set
    member val note = "" with get, set

    member this.Score(name: string) =
        this.players <-
            this.players
            |> List.map (fun p -> if p.Name = name then { p with Score = p.Score + 1 } else p)

    override this.render() =
        // A new list on every render, equal to the last one unless a score changed.
        let ranked = this.players |> List.sortByDescending (fun p -> p.Score)

        html
            $"""
        <div class="controls">
            {this.players |> List.map (fun p -> html $"<button @click={fun _ -> this.Score p.Name}>+1 {p.Name}</button>")}
            <label>Note <input .value={this.note} @input={Ev.value (fun note -> this.note <- note)}></label>
        </div>
        <div class="boards">
            <my-ranking .players={ranked}></my-ranking>
            <my-equality-ranking .players={ranked}></my-equality-ranking>
        </div>"""

defineElement<Ranking> "my-ranking"
defineElement<EqualityRanking> "my-equality-ranking"
defineElement<Scoreboard> "my-scoreboard"

Both rankings update when a score changes. While you type a note, the first ranking renders again on every key and the second doesn't. The next section explains why.

Change detection and immutable values

When a property is set, Lit compares the new value with the old one by identity (JavaScript's Object.is), and renders only if they differ. A number or a string is compared by value. A list or a record is compared by reference: the same object or not.

F# lists, records and maps are immutable, so changing one produces a new value, and Lit notices (the Templates guide covers values changed in place, which it doesn't). But a new list can hold the same players as the old one. The scoreboard sorts its players in render, and List.sortByDescending returns a new list every time. Typing a note re-renders the scoreboard, which passes a new but equal list to both rankings, and the first ranking renders again for nothing.

hasChanged replaces Lit's comparison. The second ranking declares hasChanged = fun next prev -> next <> prev, and F#'s <> compares lists and records by their contents, so an equal list isn't a change. The comparison walks the whole value on every set, so for a long list it can cost more than the render it saves. There, compare something cheaper, such as an id or a version number, or keep the list out of render so it's only rebuilt when it changes.

Reflect a property to its attribute

With reflect = true, Lit copies the property to its attribute after each change. Selectors can then see the component's state: its own :host([expanded]) rule, the page's CSS, and querySelector. The star rating reflects its value: inspect it in your browser's developer tools and click a star, and the attribute follows.

open Fable.Core
open Fable.Core.JsInterop
open Firelight
open type Firelight.Lit

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

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

    static member styles =
        css
            $$"""
        :host { display: block; }
        :host([expanded]) { outline: 2px solid var(--accent, orange); }
        .body { display: none; }
        :host([expanded]) .body { display: block; }
        """

    member val expanded = false with get, set

    override this.render() =
        html
            $"""
        <button aria-expanded={string this.expanded} @click={fun _ -> this.expanded <- not this.expanded}>Details</button>
        <div class="body"><slot></slot></div>"""

defineElement<Disclosure> "my-disclosure"

Reflect what selectors or the page need to see. Don't reflect values that change many times a second or that are large: each change writes the attribute. A reflected default appears on the element as soon as it renders. With useDefault = true it doesn't, and removing the attribute puts the default back.

Internal state

Some values only the component itself sets: whether a menu is open, what's been typed so far. Declare them with state = true. They render when they change, like any property, but have no attribute.

open Fable.Core
open Firelight
open type Firelight.Lit

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

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

    member val count = 0 with get, set

    override this.render() =
        html $"""<button @click={fun _ -> this.count <- this.count + 1}>Clicked {this.count} times</button>"""

defineElement<Counter> "my-counter"

state is about attributes, not access. The member is still public in F# and in JavaScript. When a component's state grows past a few values, or changes in ways that are worth naming, keep it in one immutable model with Firelight.Elmish instead.

Common mistakes

The compiler catches a few:

You wrote The compiler says Write instead
PropertyDeclaration<int>() without open type Firelight.Lit Invalid use of a type name open type Firelight.Lit
attribute = 5 This expression was expected to have type 'PropertyAttribute' but here has type 'int' A name, attribute = "count", or false
PropertyDeclaration<int>(type = …) Unmatched '(' ``type`` = …
jsConstructor<Array> with open System Fable: Only declared types define a function constructor in JS jsConstructor<Globals.Array>
hasChanged with the wrong types This expression was expected to have type 'Player list' but here has type 'string' A function of two 'Ts, the type in PropertyDeclaration<'T>

Most compile, because Lit reads the declarations at run time:

You wrote What happens Write instead
"Name" in properties, member val name The greeting never changes: the name attribute sets a separate Name property, and setting name doesn't render The member's exact name, or nameof
No [<AttachMembers>] Lit can't find the members: attributes and property sets are ignored [<AttachMembers>] on every component
PropertyDeclaration<int64>() or <decimal> set from an attribute The value is the attribute's text, a string A converter that parses it
open="false" on a bool property true: the attribute is present Leave the attribute out, or bind ?open={isOpen}
<my-list max-items="5"> for maxItems Ignored: Lit listens for maxitems attribute = "max-items"
items="{list}" or items={list} with a list The list becomes text .items={list}
A list or record built in render and passed down The child renders every time the parent does hasChanged, or build it outside render
let mutable title = "Notes" in the class, or a let named id, hidden, lang or another HTMLElement property Fable makes a class let a property of the element itself, so this sets the element's title, which adds a title attribute. In a template the element gets a tooltip; document.createElement fails with "The result must not have attributes" and leaves an HTMLUnknownElement Another name, such as heading, or member val private Heading = "Notes", which Fable stores under Heading@

The Templates guide lists more mistakes, in templates.