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.