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:
- No intermediate plumbing: a provider component declares a value, and any descendant component can request it. The components in between don't know the value passes through them.
- Subtree scoping: unlike global stores or signals, context follows the DOM hierarchy. Nest providers to scope a value locally, for example a dark theme for a single card while the rest of the page stays light.
- Typed keys: a Firelight context key is branded with the value's F# type, so a consumer that
requests a
Themereceives aTheme. When the provider's value changes, subscribed consumers re-render.
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