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"
By identity
- Ana: 2
- Ben: 1
Renders: 1
By equality
- Ana: 2
- Ben: 1
Renders: 1
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.