Packages

Firelight.Context

Implicitly share values with any descendant component in the DOM tree, without prop drilling.

Why use it

Some values are needed deep in the component tree: the current theme, the active user, or an Elmish dispatch function. Passing them down as properties forces every component in between to accept and forward values it doesn't use.

Context solves this with an event-based protocol that runs through the DOM:

Example

The provider owns the theme and shows its children through a slot. The badges are nested inside plain <div>s, and nothing passes the theme to them.

module Snippets.ThemeContext

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

type Theme =
    | Ember
    | Ocean

// A context is a symbol branded with the type of value it carries.
type ThemeContext =
    inherit Context<Theme>
    inherit symbol

let themeContext: ThemeContext = LitContext.createContext (JS.Symbol "theme")

/// Provides the current theme to everything inside it, however deeply nested.
[<AttachMembers>]
type ThemeProvider() =
    inherit LitElement()

    let mutable theme = Ember
    let provider = ContextProvider(jsThis, ContextProvider.Options(themeContext, theme))

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 1rem; justify-items: start; }
        button { font: inherit; padding: 0.4rem 0.9rem; cursor: pointer; }
        """

    member this.Toggle() =
        theme <- if theme = Ember then Ocean else Ember
        provider.setValue theme
        this.requestUpdate ()

    override this.render() =
        html
            $"""
        <button @click={fun _ -> this.Toggle()}>Switch theme</button>
        <slot></slot>"""

/// Reads the theme from the nearest provider. Nothing in between passes it along.
[<AttachMembers>]
type ThemedBadge() =
    inherit LitElement()

    let theme =
        ContextConsumer(jsThis, ContextConsumer.Options(themeContext, subscribe = true))

    static member styles =
        css
            $$"""
        span { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 1rem; color: white; }
        .Ember { background: #c2410c; }
        .Ocean { background: #1971c2; }
        """

    override _.render() =
        let name = string (theme.value |> Option.defaultValue Ember)
        html $"""<span class={name}>{name}</span>"""

defineElement<ThemeProvider> "my-theme-provider"
defineElement<ThemedBadge> "my-themed-badge"

The Todo demo uses the same pattern to share its Elmish state and dispatch with each item.

Providers that load later

A consumer asks for its value once, when it connects. If the provider's element isn't defined yet, because its module loads later or lazily, nobody answers and the consumer waits for good. A ContextRoot attached to the page keeps those requests and sends them again when a provider connects. It keeps only requests from consumers with subscribe = true.

Here the consumer connects inside a <my-late-provider> that isn't defined yet. Click the button to define it:

module Snippets.LateProvider

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

type GreetingContext =
    inherit Context<string>
    inherit symbol

let greetingContext: GreetingContext =
    LitContext.createContext (JS.Symbol "greeting")

// Keeps the requests nobody answered, and sends them again when a provider appears.
ContextRoot().attach document.body

/// Asks for the greeting as soon as it connects, before any provider exists.
[<AttachMembers>]
type LateConsumer() =
    inherit LitElement()

    // ContextRoot only keeps requests that subscribe.
    let greeting =
        ContextConsumer(jsThis, ContextConsumer.Options(greetingContext, subscribe = true))

    override _.render() =
        match greeting.value with
        | Some text -> html $"<p>{text}</p>"
        | None -> html $"<p>Waiting for a provider…</p>"

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

    let provider =
        ContextProvider(jsThis, ContextProvider.Options(greetingContext, "Hello from the provider"))

    override _.render() = html $"<slot></slot>"

/// Defines <my-late-provider> on click, so the provider upgrades after its consumer asked.
[<AttachMembers>]
type ProviderLoader() =
    inherit LitElement()

    let mutable loaded = false

    member this.Load() =
        defineElement<LateProvider> "my-late-provider"
        loaded <- true
        this.requestUpdate ()

    override this.render() =
        html $"""<button ?disabled={loaded} @click={fun _ -> this.Load()}>Define the provider</button>"""

defineElement<LateConsumer> "my-late-consumer"
defineElement<ProviderLoader> "my-provider-loader"

Attach the root once, before the consumers connect, to an element above both: document.body, or your app's root element.

Install

dotnet add package Firelight.Context
npm install @lit/context