Guides

Component communication

A parent passes data to a child by setting its properties, and a child tells its parent what happened by raising an event. These are the DOM's own channels, so a Firelight component talks to any element the same way. For components far apart, share state through context, signals or a single Elmish loop.

Only components need these patterns. A template function gets everything it shows as arguments, dispatch included, so most of an app's pieces talk to each other through ordinary F# function calls. The patterns here are for the places where one custom element has to reach another.

Choosing a pattern

To share Use Example
Data from a parent to its child Properties <my-chart .points={points}>
What happened, from a child to its parent Events @color-picked={...}
A value for a whole subtree Context A theme, the signed-in user, dispatch
State between unrelated components Signals A basket count in the header and on a page
The state of an app or a feature One Elmish loop A board, its columns and cards

Start with properties and events. They keep each component independent: a child knows nothing about its parent, and a parent only knows the child's public properties and events. The other three are for state that many components need, which otherwise passes through every layer in between.

Parent to child: properties

A parent sets its child's properties in its template, as it would for any element. The child declares them in static member properties, and Lit re-renders it when they change.

Bind a property with a dot, .colors={colors}, to pass any F# value: a list, a record, a function. An attribute binding without the dot turns the value into a string, which suits only strings and numbers that you would also write in HTML. Binding values covers the difference.

Lit re-renders a child when a property gets a new value, compared by identity. An F# list or record is immutable, so a change always makes a new value, and the child always sees it.

Child to parent: events

A child reports what happened by dispatching an event from itself. It doesn't call its parent: any ancestor that cares listens, with the same @name binding as for click.

open Browser.Types
open Firelight

let pick (host: LitElement) (color: string) =
    host.dispatch (Event.customEvent ("color-picked", color))

Event.customEvent makes a CustomEvent with the value as its detail. It sets bubbles and composed, so the event travels up the tree and out of the child's shadow root, to listeners on any ancestor. In the parent's template, Ev.custom<'T> types the listener's event as a CustomEvent<'T>, whose detail is a 'T option.

This demo has both directions. The parent owns the colour and passes it to <my-swatch-picker> as properties. The picker raises color-picked when a swatch is clicked. Pick a colour, and the parent's text follows the event. Then press Reset: the parent sets the picker's selected property, and the picker follows.

module Snippets.SwatchPicker

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

let private swatch (color: string) (selected: bool) (pick: unit -> unit) =
    html $"""<button style="background: {color}" aria-label={color} aria-pressed={selected} @click={fun _ -> pick ()}></button>"""

/// The child: shows the colours its parent gives it, and raises color-picked when one is clicked.
[<AttachMembers>]
type SwatchPicker() =
    inherit LitElement()

    static member properties =
        PropertyDeclarations.create [
            "colors", PropertyDeclaration<string list>(attribute = false)
            "selected", PropertyDeclaration<string>()
        ]

    static member styles =
        css
            $$"""
        :host { display: flex; gap: 0.5rem; }
        button { width: 2rem; height: 2rem; border: 2px solid transparent; border-radius: 50%; cursor: pointer; }
        button[aria-pressed="true"] { outline: 3px solid var(--fg); outline-offset: 2px; }
        """

    member val colors: string list = [] with get, set
    member val selected = "" with get, set

    member this.Pick(color: string) =
        this.selected <- color
        this.dispatch (Event.customEvent ("color-picked", color))

    override this.render() =
        html $"""{[ for color in this.colors -> swatch color (color = this.selected) (fun () -> this.Pick color) ]}"""

/// The parent: owns the colour, passes it down as properties and listens for the child's event.
[<AttachMembers>]
type SwatchParent() =
    inherit LitElement()

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

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

    member val color = "teal" with get, set

    override this.render() =
        html
            $"""
        <p>The parent's colour is <strong>{this.color}</strong>.</p>
        <my-swatch-picker
            .colors={[ "teal"; "orange"; "purple"; "crimson" ]}
            .selected={this.color}
            @color-picked={Ev.custom<string> (fun e -> e.detail |> Option.iter (fun c -> this.color <- c))}>
        </my-swatch-picker>
        <button @click={fun _ -> this.color <- "teal"}>Reset to teal</button>"""

defineElement<SwatchPicker> "my-swatch-picker"
defineElement<SwatchParent> "my-swatch-parent"

The picker sets its own selected before raising the event, as a native <select> changes before it fires change. It works the same whether or not anyone listens.

This parent accepts every pick. A parent that refuses one has to put the picker back, and its own value hasn't changed, so Lit skips the .selected binding as unchanged. Bind .selected={live this.color} and call this.requestUpdate () when refusing: live compares with the picker's current selected, not with the last value rendered.

A parent could pass a callback down instead, as a property: .onPick={fun color -> ...}. That works, and it's typed, but only the parent that set it hears about the pick. An event reaches any ancestor and any listener added from outside, and the child stays usable from plain HTML. Prefer events for components that others will use, and callbacks only inside a component you own.

For event names, detail types, listener options and listening outside templates, see the Events guide.

Components far apart

An event that bubbles and is composed already reaches distant ancestors, with no handlers in between. Properties don't travel that way: when the component with the data is several layers above the one that needs it, each layer in between has to accept the value and pass it on. Context, signals and a single Elmish loop each avoid that forwarding.

Context

A provider component holds a value, and any component inside it asks for that value by the context's key. The components in between don't know it exists. When the provider sets a new value, consumers that subscribed re-render. It suits values that a whole subtree reads: the theme, the signed-in user, the language, or an Elmish dispatch function.

A consumer gets its value from its nearest provider among its ancestors. The value is a 'T option, which is None until a provider answers, and stays None for a component outside every provider. Firelight.Context has the code and a demo.

Signals

A signal is a value outside any component, usually in a module. Components registered with LitSignals.defineElement re-render when a signal they read changes, wherever they are on the page. The components need no common ancestor, so signals suit state that unrelated parts of a page share, such as a count in the header and a list in the main content.

A signal in a module is one value for the whole page, shared by every component that reads it. Keep the state of a single widget in the widget. Lit's signals are experimental. Firelight.Signals has the code and a demo.

One Elmish loop

For an app or a feature with one model, run one Elmish loop in the component at its top, and draw the rest with template functions that take the model, or the part they show, and dispatch:

open Fable.Core
open Firelight
open Firelight.Elmish
open type Firelight.Lit

type Model = { Items: string list; Selected: string option }

type Msg = Select of string

let update (Select item) model = { model with Selected = Some item }

let itemView (selected: string option) (dispatch: Msg -> unit) (item: string) =
    html $"""<li><button aria-pressed={(selected = Some item)} @click={fun _ -> dispatch (Select item)}>{item}</button></li>"""

let listView (model: Model) dispatch =
    html $"""<ul>{model.Items |> List.map (itemView model.Selected dispatch)}</ul>"""

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

    let elmish =
        ElmishController.simple this (fun () -> { Items = [ "One"; "Two" ]; Selected = None }) update

    override _.render() = listView elmish.model elmish.dispatch

Every view reads the same model and sends messages to the same update, so there is nothing to keep in step. The Kanban demo is built this way: one component, one loop, and template functions for the columns and cards.

When a part of the view needs to be a component, for its own lifecycle or a repeat that keeps its element, give it the model and dispatch as properties or through context. The Todo demo provides both through context, and each item component reads them.

Inside one loop, views don't need DOM events to reach each other: a message already reaches every view through the model. Keep update plain F#, with no DOM in it. Raise an event only to tell something outside the loop, such as the page around the component, and do it from a command.

When to use which

The patterns mix. An app can keep its data in an Elmish loop, provide the theme through context, and use properties and events for a date picker inside a form. Pick the most local one that works: properties and events are the easiest to follow, because the template shows where data goes.

Common mistakes

The compiler catches one:

You wrote The compiler says Write instead
Ev.custom<string> (fun e -> this.color <- e.detail) Type constraint mismatch. The type 'string option' is not compatible with type 'string' e.detail |> Option.iter (fun c -> this.color <- c)

The rest compile, because a template's holes, event names and detail types aren't checked:

You wrote What happens Write instead
colors={colors}, for a list The child gets an attribute, not the list .colors={colors}
this.colors.Add c, on a ResizeArray passed down The child doesn't re-render An F# list, assigned anew: this.colors <- this.colors @ [ c ]
CustomEvent.Create ("color-picked", init), with the default options Only a listener on the child itself hears it. It doesn't bubble or leave a shadow root Event.customEvent ("color-picked", c)
@colour-picked={...}, a different spelling Nothing happens The exact name the child dispatches
Ev.custom<int> for a string detail detail is a string at run time, whatever the type says The type the child sends
A consumer rendered outside every provider Its value stays None Render it inside the provider, or pass the value as a property
A consumer moved out of its provider, such as in a dialog moved to document.body It keeps the last value it got and stops updating Keep it inside the provider

An event's name is part of the template's fixed text, so it can't come from an F# constant. Copy it exactly, and keep each component's event names in one place, such as a comment on the component.