Guides

Controllers

Write a reactive controller to bundle behaviour that has state and needs setting up and tearing down, such as a timer, a media query or a request. Any component can then use it: create it with the component as its host, and read it in render. The controller follows the component's lifecycle and asks it to render when its state changes.

A component adds a controller with one line, such as let watch = Stopwatch(this), and reads it in render. The controller does the rest: it starts and stops with the component and keeps its own state. Several of Firelight's packages are controllers, and a component can use as many as it needs.

Writing a controller

A controller is a class that inherits ReactiveControllerBase and registers itself with its host, the component, when it's created. It overrides the methods it needs, of four, one for each point in the host's lifecycle:

Method Called Runs on the server
hostConnected When the host joins a page, in Lit's connectedCallback No
hostDisconnected When the host leaves the page, in Lit's disconnectedCallback No
hostUpdate At each update, before the host renders No
hostUpdated At each update, after the host has rendered No

An update that the host's shouldUpdate skips calls neither of the last two. Lit's interface makes all four optional. ReactiveControllerBase gives each one a body that does nothing, so you override only the ones you use. It implements Firelight's ReactiveController interface, which a class can also implement directly; then F# needs all four members, with () for the ones you don't use. That's the way for a class that already inherits something else. The host is a ReactiveControllerHost, which every LitElement is. It has addController, requestUpdate and updateComplete, which is all most controllers need.

addController on a host that's already on a page calls hostConnected straight away, so set up the controller's fields before it registers. removeController doesn't call hostDisconnected: clean up first.

This stopwatch measures time and re-renders its host ten times a second while it runs. Two different components use it: one counts up, the other counts down from ten seconds. Start both, then stop the stopwatch: the countdown carries on, as each component has its own stopwatch.

module Snippets.StopwatchTimers

open Fable.Core
open Firelight
open type Firelight.Lit

/// Milliseconds from a clock that only goes forward, unlike the time of day.
[<Emit("performance.now()")>]
let private now () : float = jsNative

/// Measures time while running. It re-renders its host every 100 ms, but only while the host is
/// on the page: the time is worked out from the clock, so it stays right while nothing renders.
type Stopwatch(host: ReactiveControllerHost) as this =
    inherit ReactiveControllerBase()

    let mutable counted = 0.0 // ms, before the latest start
    let mutable startedAt: float option = None
    let mutable timer: int option = None
    let mutable connected = false

    let startTicking () =
        if connected && timer.IsNone then
            timer <- Some(JS.setInterval (fun () -> host.requestUpdate ()) 100)

    let stopTicking () =
        timer |> Option.iter JS.clearInterval
        timer <- None

    do host.addController this

    member _.Running = startedAt.IsSome

    /// Milliseconds counted so far.
    member _.Elapsed =
        match startedAt with
        | Some t -> counted + now () - t
        | None -> counted

    member _.Start() =
        if startedAt.IsNone then
            startedAt <- Some(now ())
            startTicking ()
            host.requestUpdate ()

    member this.Stop() =
        counted <- this.Elapsed
        startedAt <- None
        stopTicking ()
        host.requestUpdate ()

    member this.Reset() =
        this.Stop()
        counted <- 0.0

    override _.hostConnected() =
        connected <- true

        if startedAt.IsSome then
            startTicking ()

    override _.hostDisconnected() =
        connected <- false
        stopTicking ()

let private styles =
    css
        $$"""
    :host { display: grid; gap: 0.5rem; justify-items: start; }
    .time { font: 600 1.6rem var(--mono); }
    button { font: inherit; padding: 0.3rem 0.8rem; }
    """

let private startStop (watch: Stopwatch) =
    if watch.Running then
        html $"""<button @click={fun _ -> watch.Stop()}>Stop</button>"""
    else
        html $"""<button @click={fun _ -> watch.Start()}>Start</button>"""

/// Counts up.
[<AttachMembers>]
type StopwatchView() as this =
    inherit LitElement()

    let watch = Stopwatch(this)

    static member styles = styles

    override _.render() =
        let seconds = watch.Elapsed / 1000.0

        html
            $"""
        <span class="time">{seconds.ToString "0.0"} s</span>
        <div>{startStop watch} <button @click={fun _ -> watch.Reset()}>Reset</button></div>"""

/// Counts down from ten seconds, with the same controller.
[<AttachMembers>]
type CountdownView() as this =
    inherit LitElement()

    let watch = Stopwatch(this)
    let length = 10000.0

    static member styles = styles

    // Stop at zero. Stopping here is part of this update, not another one.
    override _.willUpdate(_) =
        if watch.Running && watch.Elapsed >= length then
            watch.Stop()

    override _.render() =
        let left = max 0.0 (length - watch.Elapsed)

        let status =
            if left = 0.0 then
                html $"""<span class="time">Done</span>"""
            else
                html $"""<span class="time">{ceil (left / 1000.0)} s left</span>"""

        html
            $"""
        {status}
        <progress max={length} value={length - left} aria-label="Countdown"></progress>
        <div>{startStop watch} <button @click={fun _ -> watch.Reset()}>Reset</button></div>"""

defineElement<StopwatchView> "my-stopwatch"
defineElement<CountdownView> "my-countdown"

How the parts fit:

A controller that reads the browser

Controllers are a natural home for browser APIs that a component would otherwise set up and tear down itself. This one tracks a CSS media query, such as the system's dark mode:

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

// Fable.Browser.Dom has no matchMedia, so declare the part this needs.
type MediaQueryList =
    inherit EventTarget
    abstract matches: bool

[<Emit("window.matchMedia($0)")>]
let matchMedia (query: string) : MediaQueryList = jsNative

/// Whether a CSS media query matches, such as "(prefers-color-scheme: dark)".
type MediaQuery(host: ReactiveControllerHost, query: string) as this =
    inherit ReactiveControllerBase()

    let mutable matches = false
    let mutable stopListening = ignore

    do host.addController this

    member _.Matches = matches

    override _.hostConnected() =
        let list = matchMedia query

        let read () =
            matches <- list.matches
            host.requestUpdate ()

        stopListening <- Ev.listen list "change" (Ev.event (fun _ -> read ()))
        // After the host's first update: see below.
        promise {
            let! _ = host.updateComplete
            read ()
        }
        |> Promise.start

    override _.hostDisconnected() = stopListening ()

[<AttachMembers>]
type ThemeNote() as this =
    inherit LitElement()

    let dark = MediaQuery(this, "(prefers-color-scheme: dark)")

    override _.render() =
        html $"""<p>Your system prefers a {if dark.Matches then "dark" else "light"} theme.</p>"""

It touches window only in hostConnected, so a component that uses it can still be prerendered at build time, where there is no window. It also reads the query only after the host's first update. The build can't know the answer, so the prerendered page says "light", and a prerendered component's first render in the browser has to match it. Reading the query in hostConnected would change that first render, and the prerendered text would stay on the page. Without prerendering, read it in hostConnected and skip the wait. Prerendering components at build time has the rules.

Keep the cleanup next to the setup, as stopListening does: Ev.listen returns the function that removes its listener. Removing one with removeEventListener yourself needs the very function addEventListener was given, which a method or a class-level let function isn't (see Events).

Controllers in Firelight's packages

Several packages ship controllers. You use them like your own: create them in a let binding with the component as the host, and read them in render.

Package Controller Gives the component
Firelight.Observers ResizeController, IntersectionController, MutationController, PerformanceController A value from the browser's observers: its size, whether it's on screen
Firelight.Task LitTask Async work, run when its arguments change, with pending, complete and error states
Firelight.Elmish ElmishController An Elmish loop: model and dispatch
Firelight.Context ContextProvider, ContextConsumer Values shared down the tree
Firelight.Router RouterController The route for the current address
Firelight.Motion AnimateController Control over the component's animations

The Observers controllers are a good example of what a controller is for. Each wraps one of the browser's observers: it starts observing when the host connects, stops when it disconnects, turns the observer's entries into a value through your callback, and asks the host to render. The Observers package page has a panel that lays itself out by its own width.

LitTask uses hostUpdate: before each render, it checks its arguments and starts the task when they've changed. The Task package page has an example. ElmishController uses hostConnected and hostDisconnected to ask for updates only while the host is on a page.

Controller, function, component or base class

A controller is one of four ways to reuse code between components:

Common mistakes

The compiler catches some controller mistakes:

You wrote The compiler says Write instead
interface ReactiveController with and only hostConnected and hostDisconnected No implementation was given for those members: 'abstract ReactiveController.hostUpdate: unit -> unit' … inherit ReactiveControllerBase() and override what you need, or member _.hostUpdate() = () for each method you don't
member _.hostConnected() = ... in a class that inherits ReactiveControllerBase Warning: This new member hides the abstract member 'abstract ReactiveControllerBase.hostConnected: unit -> unit'. Rename the member or use 'override' instead override _.hostConnected() = ...
do host.addController this, or Stopwatch(this) in a component, without as this The value or constructor 'this' is not defined type Stopwatch(host: ReactiveControllerHost) as this =

Others compile:

You wrote What happens Write instead
No host.addController this Lit never calls the controller's methods: it never starts, or never stops Register it in its constructor
Stopwatch(this) in render A new controller at each render, each one added to the host and never removed A let binding in the component
A timer or listener started in the controller's constructor It runs before the host is on a page, and on the server when prerendering Start it in hostConnected
Nothing undone in hostDisconnected The work goes on after the component has gone, holding it in memory Undo in hostDisconnected what hostConnected did
host.requestUpdate () in hostUpdated A second update after every update Request an update where the state changes