Guides

Events

Components talk to the page the way built-in elements do: they listen for DOM events and raise their own. Firelight adds typed handlers and a helper for custom events. The events themselves are the browser's, so anything on the page can listen, in F# or not.

A button raises click, and a text box raises input. A component can raise count-changed or note-closing in the same way, and whoever uses it listens with @count-changed in a template or addEventListener in JavaScript. Nothing about the component's F# types leaks into the page.

This guide covers events on one component and its parent. How larger apps pass data between components is the subject of the component communication guide.

Listen with @event

An @event binding adds a listener to the element it's on:

open Firelight
open type Firelight.Lit

let saveButton (save: unit -> unit) =
    html $"""<button @click={fun _ -> save ()}>Save</button>"""

The hole takes any function of the event. A new lambda on each render is fine: Lit keeps one DOM listener and calls the newest function.

Typed handlers with Ev

A hole's type is obj, so F# can't infer what the event is. The Ev module supplies the type, as the Templates guide shows: in Ev.keyboard (fun e -> ...), e is a KeyboardEvent. Pick the function for the event you bind:

Function Your function gets Bind it to
Ev.mouse A MouseEvent click, dblclick, contextmenu and the other mouse events
Ev.pointer A PointerEvent pointerdown, pointermove, pointerup and so on
Ev.keyboard A KeyboardEvent keydown, keyup
Ev.focus A FocusEvent focus, blur, focusin, focusout
Ev.input An InputEvent input, beforeinput
Ev.wheel A WheelEvent wheel
Ev.drag A DragEvent dragstart, dragover, drop and so on
Ev.touch A TouchEvent touchstart, touchmove, touchend
Ev.submit A SubmitEvent submit on a <form>
Ev.custom<'T> A CustomEvent<'T> A component's own events: Typed details
Ev.event An Event Anything else, such as change
Ev.value The field's value, a string input or change on the field
Ev.checked' Whether the box is checked change on the checkbox
Ev.valueAs<'T> The element's value, unboxed as a 'T A component whose value isn't a string
Ev.slot The <slot>, an HTMLSlotElement slotchange on the slot

Ev.value and Ev.checked' read the element the listener is on, so bind them on the field itself, not on a <form> around it. Ev.valueAs<'T> is for components whose value property holds something other than a string, such as a slider's number: it unboxes the value and parses nothing. A native <input type="number">'s value is still a string, so use Ev.value there and parse it. Ev.slot also reads the element the listener is on, so bind it on the <slot>; the slots recipe uses it. Nothing checks the function against the event's name; see Typed details.

A few members of the event are worth knowing in any handler:

Member What it is Typical use
e.preventDefault () Stops the browser's own action A form's submit, a link's navigation
e.currentTarget The element the listener is on Reading the field that changed
e.target Where the event started, as seen from the listener Finding which child was clicked
e.stopPropagation () Stops the event reaching listeners further up Rarely; it hides the event from the page too

A form is the common case for preventDefault. Without it, submitting reloads the page:

open Firelight
open type Firelight.Lit

let searchForm (query: string) (setQuery: string -> unit) (search: unit -> unit) =
    let submit =
        Ev.submit (fun e ->
            e.preventDefault ()
            search ())

    html
        $"""
    <form @submit={submit}>
        <input aria-label="Search" .value={query} @input={Ev.value setQuery}>
        <button>Search</button>
    </form>"""

Listener options

For the options of addEventListener, bind a LitEventListener instead of a function. passive promises the browser that the handler won't call preventDefault, so it can scroll without waiting for it. once removes the listener after the first event, and capture runs it on the way down, before the event reaches its target.

open Browser.Types
open Firelight
open type Firelight.Lit

let zoomArea (zoom: float -> unit) =
    let onWheel = LitEventListener(Ev.wheel (fun e -> zoom e.deltaY), passive = true)

    html $"""<div class="canvas" @wheel={onWheel}><slot></slot></div>"""

Listen outside the component

A template's listeners only cover the component's own elements. For a key pressed anywhere, or a click outside a menu, add a listener to window or document in connectedCallback, and remove it in disconnectedCallback. Otherwise every component that's been removed keeps listening, and keeps the component alive in memory.

Ev.listen target name handler adds the listener and returns a function that removes it. Keep that function, and call it on the way out:

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

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

    let mutable stopListening = ignore

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

    member val isOpen = false with get, set

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

        stopListening <-
            Ev.listen window "keydown" (Ev.keyboard (fun e -> if e.key = "Escape" then this.isOpen <- false))

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

    override this.render() =
        html
            $"""
        <button @click={fun _ -> this.isOpen <- not this.isOpen}>Menu</button>
        {if this.isOpen then html $"<ul><li>Escape closes this</li></ul>" else nothing}"""

defineElement<Menu> "my-menu"

The handler can be typed with any Ev function; listen doesn't check it against the event's name. connectedCallback also keeps window out of the constructor and render, which Lit SSR runs where there is no window.

Calling window.addEventListener and removeEventListener yourself works too, with one trap: removeEventListener only removes the very function it's given. A method, or a function bound with let in the class, doesn't qualify: Fable wraps it in a new JavaScript function each time it's passed, so removing it removes nothing. The remover Ev.listen returns holds the one function it added.

Raise an event

To tell its parent something happened, a component dispatches an event on itself:

open Fable.Core
open Browser.Types
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

    member this.Increment() =
        this.count <- this.count + 1
        this.dispatch (Event.customEvent ("count-changed", this.count))

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

defineElement<Counter> "my-counter"

Event.customEvent (name, detail) comes with open Browser.Types. It makes a CustomEvent that carries detail and, by default, bubbles and is composed: the next section explains both. this.dispatch raises it from the component. It's Lit's dispatchEvent without the result: dispatchEvent returns false if a listener cancelled the event, and true otherwise, which only matters for an event a listener can cancel.

Name events like the browser's own, in lower case with hyphens. Event names are case-sensitive, so a listener for countchanged misses countChanged. Raise the event after the change, so a listener that reads the component's properties sees the new values. Its DOM is still the old one until the next render; to raise an event once that's done, wait for this.updateComplete first.

Typed details

detail can be any F# value: a number, a record, a union. Nothing serialises it, so the listener gets the same object. In a template, Ev.custom<'T> types the handler, and e.detail is a 'T option, None if the event came without one.

Nothing connects the two ends. The name in @count-changed is template text that only Lit reads, and Ev.custom<string> compiles on an event whose detail is an int. Keep the name, the detail type and the handler together in one module, so there is one place to get them right:

open Browser.Types
open Firelight
open type Firelight.Lit

type Swatch = { Name: string; Hex: string }

module SwatchPicked =
    /// Bind as @swatch-picked.
    let name = "swatch-picked"

    let raise (host: LitElement) (swatch: Swatch) =
        host.dispatch (Event.customEvent (name, swatch))

    let handle (onPicked: Swatch -> unit) =
        Ev.custom<Swatch> (fun e -> e.detail |> Option.iter onPicked)

let picker (pick: Swatch -> unit) =
    html $"""<my-swatch-picker @swatch-picked={SwatchPicked.handle pick}></my-swatch-picker>"""

A JavaScript listener sees Fable's representation of the value. A record arrives as an object with its fields, and a string or a number as itself, but a union, a list or a Map arrive as Fable's own classes. For events that pages written in JavaScript will handle, use strings, numbers, records or anonymous records.

Events a listener can cancel

Some events ask permission: the component raises one before it acts, and acts only if no listener calls preventDefault. Such an event must be created with cancelable = true, and raised with dispatchEvent, whose result says whether a listener cancelled it.

Tick the box, then close the note:

module Snippets.EventCancel

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

/// A note that asks before it closes: it raises a cancelable "note-closing" event, with its heading
/// as the detail, and closes only if no listener cancels it.
[<AttachMembers>]
type ClosableNote() =
    inherit LitElement()

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

    static member styles =
        css
            $$"""
        :host { display: block; }
        .note { display: flex; gap: 0.75rem; align-items: center; padding: 0.4rem 0.75rem; border: 1px solid var(--border); border-radius: 0.5rem; }
        button { font: inherit; padding: 0.2rem 0.6rem; }
        """

    member val heading = "Note" with get, set
    member val closed = false with get, set

    member this.Close() =
        let closing =
            Event.customEvent ("note-closing", this.heading, cancelable = true)
        // false when a listener called preventDefault.
        if this.dispatchEvent closing then
            this.closed <- true

    override this.render() =
        if this.closed then
            html $"""<button @click={fun _ -> this.closed <- false}>Reopen {this.heading}</button>"""
        else
            html
                $"""
            <div class="note">
                <strong>{this.heading}</strong>
                <button @click={fun _ -> this.Close()}>Close</button>
            </div>"""

/// Cancels "note-closing" while the box is ticked.
[<AttachMembers>]
type NoteBoard() =
    inherit LitElement()

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

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.75rem; justify-items: start; }
        p { margin: 0; }
        """

    member val keepOpen = false with get, set
    member val message = "Nothing has closed yet." with get, set

    override this.render() =
        let onClosing =
            Ev.custom<string> (fun e ->
                let heading = e.detail |> Option.defaultValue "A note"

                if this.keepOpen then
                    e.preventDefault ()
                    this.message <- $"{heading} stays open."
                else
                    this.message <- $"{heading} closed.")

        html
            $"""
        <label><input type="checkbox" .checked={this.keepOpen}
            @change={Ev.checked' (fun on -> this.keepOpen <- on)}> Keep notes open</label>
        <my-closable-note heading="Shopping" @note-closing={onClosing}></my-closable-note>
        <p role="status">{this.message}</p>"""

defineElement<ClosableNote> "my-closable-note"
defineElement<NoteBoard> "my-note-board"

The note raises note-closing, and closes only if dispatchEvent returns true. The board calls e.preventDefault () while the box is ticked. Without cancelable, preventDefault does nothing and dispatchEvent returns true regardless.

dispatchEvent runs every listener before it returns, so a listener must decide at once. One that waits, say for a confirmation dialog or a request, is too late: by the time it calls preventDefault, the note has closed. To ask first, cancel the event, then tell the component to close later.

How far an event goes

An event starts at its target and, if it bubbles, travels up through each ancestor, where listeners can hear it. Shadow DOM adds a boundary: an event raised inside a shadow root stops at it unless it's composed. The browser's own UI events, such as click, input and keydown, are both. CustomEvents are neither unless you say so, which is why Event.customEvent sets both by default.

this.dispatchEvent raises the event on the component's host element, which lives in its parent's tree, not its own shadow root. So the parent hears it even when it isn't composed. Composed only decides whether it goes further: out of the parent's shadow root, to the page and the components around it.

This component has the buttons in its shadow DOM, and listens to them. The page listens on document. Raise a few signals of each kind:

module Snippets.EventPath

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

type Signal = { Number: int }

/// Raises "signal" from its host, composed or not.
[<AttachMembers>]
type SignalButtons() =
    inherit LitElement()

    static member styles =
        css
            $$"""
        :host { display: flex; flex-wrap: wrap; gap: 0.5rem; }
        button { font: inherit; padding: 0.2rem 0.6rem; }
        """

    member val sent = 0 with get, set

    member this.Raise(composed: bool) =
        this.sent <- this.sent + 1
        let signal = { Number = this.sent }
        this.dispatch (Event.customEvent ("signal", signal, composed = composed))

    override this.render() =
        html
            $"""
        <button @click={fun _ -> this.Raise true}>Raise, composed</button>
        <button @click={fun _ -> this.Raise false}>Raise, not composed</button>"""

/// Has a <my-signal-buttons> in its shadow DOM and listens to it.
[<AttachMembers>]
type EventPath() =
    inherit LitElement()

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

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.75rem; padding: 0.75rem; border: 1px dashed var(--border); border-radius: 0.5rem; }
        p { margin: 0; }
        """

    member val heard = "The component hasn't heard a signal yet." with get, set

    override this.render() =
        let onSignal =
            Ev.custom<Signal> (fun e ->
                let target = (e.target :?> HTMLElement).localName

                e.detail
                |> Option.iter (fun s -> this.heard <- $"The component heard signal {s.Number}. Its target: <{target}>."))

        html
            $"""
        <my-signal-buttons @signal={onSignal}></my-signal-buttons>
        <p>{this.heard}</p>"""

defineElement<SignalButtons> "my-signal-buttons"
defineElement<EventPath> "my-event-path"

The page hasn't heard a signal yet.

The component hears every signal. The page hears only the composed ones, and their target is <my-event-path>, not the buttons component. As an event leaves a shadow root, the browser retargets it to the host, so listeners outside don't see inside. e.composedPath () still lists the elements it passed through, starting from the real one, except those inside closed shadow roots. Lit's are open unless you say otherwise.

Keep the defaults for an event that's part of the component's public face, such as count-changed: the page, and any component the element ends up inside, can hear it. For an event that only the direct parent should see, such as one between two parts of a larger component, pass bubbles = false, composed = false. Then the parent hears it only on the element itself, as in <my-picker @picked={...}> (or with a capturing listener higher up).

Common mistakes

The compiler catches a few:

You wrote The compiler says Write instead
this.dispatchEvent (Event.customEvent (...)) as a statement The result of this expression has type 'bool' and is implicitly ignored (a warning) this.dispatch (...), or use the result
e.detail.Name The type 'Option<_>' does not define a field, constructor, or member named 'Name' e.detail |> Option.iter (fun s -> ...)
Event.customEvent without open Browser.Types The value, constructor, namespace or type 'customEvent' is not defined open Browser.Types

Most compile, because event names are strings and a hole accepts any value:

You wrote What happens Write instead
"countChanged" raised, @count-changed bound The handler never runs The same name at both ends, lower case with hyphens
Ev.custom<string> for a detail that's an int e.detail holds an int; string functions fail at run time The detail's real type, kept beside the name
composed = false, from a component inside another's shadow DOM The page never hears it Composed, the default of Event.customEvent
A page listener reading e.target for the inner element It gets the outermost host e.composedPath (), or put what it needs in detail
e.preventDefault () on an event made by Event.customEvent (name, detail) Nothing: the event isn't cancelable Event.customEvent (name, detail, cancelable = true)
window.addEventListener in the constructor or render Fails in Lit SSR; with no removal, listens forever connectedCallback, removed in disconnectedCallback
removeEventListener with a class let function or a method The listener stays Ev.listen, and call the function it returns