Packages

Firelight.Task

Run async work from a component and render its pending, complete and error states.

Why use it

Loading data in a component takes more than making the request. You need a loading state, the result or the error, a new request when an input changes, and a way to ignore an older request that finishes after a newer one.

LitTask handles all of that. Give it a function that returns a promise and an args function: the task runs whenever the arguments change, renders the component again as its status changes, and render picks the template for the current status. The task function also receives an AbortSignal you can pass to fetch, so requests that are no longer needed get cancelled (Cancelling requests). The F# type is called LitTask so it doesn't clash with .NET's Task.

Example

Each button changes the task's argument, which starts a new request. Product 3 fails, to show the error state. Clear sets the argument to an empty id, for which the task function returns initialState: the task goes back to its initial state, which render shows with its initial template, and no request starts.

args must return a plain JavaScript array. Fable compiles numeric arrays such as int[] to typed arrays (Int32Array), which @lit/task rejects, so this example uses string ids. Arrays of strings, records or obj are fine.

module Snippets.ProductTask

open Fable.Core
open Firelight
open Firelight.Task
open type Firelight.Lit

// Stands in for a real request, such as a fetch to your API.
let fetchProduct (id: string) : JS.Promise<string> =
    promise {
        do! Promise.sleep 800

        if id = "3" then
            failwith "Product 3 is out of stock"

        return $"Product {id} is in stock"
    }

let private status =
    StatusRenderer(
        initial = (fun () -> html $"<p>Pick a product.</p>"),
        pending = (fun () -> html $"<p>Loading…</p>"),
        complete = (fun (text: string) -> html $"<p>{text}</p>"),
        error = (fun error -> html $"<p class='error'>{error}</p>")
    )

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

    // Product ids are strings: @lit/task needs a plain JS array of args, and Fable compiles int[] to an Int32Array.
    let mutable productId = "1"

    // Runs again whenever productId changes, and re-renders as the request goes
    // from pending to complete or error. With no product, initialState puts the task
    // back in its initial state, without a request. (args always holds one id, but a
    // match must cover every length, so the other lengths go there too.)
    let product =
        LitTask(
            this,
            TaskConfig(
                TaskFunction(fun (args: string[]) _ ->
                    match args with
                    // U2.Case2, not !^: nothing here says if the task's result is the string or the promise.
                    | [| id |] when id <> "" -> U2.Case2(fetchProduct id)
                    | _ -> initialState),
                args = (fun () -> [| productId |])
            )
        )

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

    member this.Show(id: string) =
        productId <- id
        this.requestUpdate ()

    member this.ProductButton(id: string) =
        html $"""<button @click={fun _ -> this.Show id}>Product {id}</button>"""

    override this.render() =
        html
            $"""
        <div>{[ "1"; "2"; "3" ] |> List.map this.ProductButton} <button @click={fun _ -> this.Show ""}>Clear</button></div>
        {product.render status |> Option.defaultValue (html $"")}"""

defineElement<ProductView> "my-product-view"

fetchProduct builds its promise with promise { }, from Fable.Promise, which comes with Firelight.Task through Fable.Fetch; reference Fable.Promise in your project when your code uses it. The task function takes the id out of args with the pattern [| id |], and wraps the promise in U2.Case2: !^ would need the task's result type, which nothing in this lambda gives it (Tasks has the details).

Cancelling requests

The task function's second argument has one member, signal. The task aborts it when the run is no longer wanted: its arguments changed and a newer run started, or you called abort. Pass it to the request, and the browser cancels the request.

Its type is Fable.Fetch's AbortSignal. Firelight.Task depends on Fable.Fetch, the binding for the browser's fetch, so with open Fetch the signal goes straight to fetch's Signal option:

open Fable.Core.JsInterop
open Fetch
open Firelight.Task

let loadText (url: string) (signal: AbortSignal) =
    promise {
        let! response = fetch url [ Signal signal ]
        return! response.text ()
    }

let loadPage (args: string[]) (options: TaskFunctionOptions) : TaskResult<string> =
    match args with
    | [| url |] -> !^(loadText url options.signal)
    | _ -> initialState

Pass it as TaskFunction loadPage.

The value is the browser's own AbortSignal object, so any other API that takes a signal accepts it too. In a binding of your own, such as a hand-written fetch, type the parameter as Fable.Fetch's AbortSignal:

open Fable.Core
open Fable.Core.JsInterop
open Firelight.Task

[<Global>]
let fetch (url: string, init: {| signal: Fetch.Types.AbortSignal |}) : JS.Promise<obj> = jsNative

let loadPage (args: string[]) (options: TaskFunctionOptions) : TaskResult<obj> =
    match args with
    | [| url |] -> !^(fetch (url, {| signal = options.signal |}))
    | _ -> initialState

Where another library's binding has an AbortSignal type of its own, convert at the call with unbox options.signal, which compiles to nothing.

Work that isn't a request can call options.signal.throwIfAborted () after each wait, as Search as you type does, or read options.signal.aborted.

Install

dotnet add package Firelight.Task
npm install @lit/task

The first command also installs Fable.Fetch.