Fetch JSON
Build a component that loads a list from a JSON file with fetch, shows a loading message, the list or the error, and can load it again.
Tick the box to ask for a file that isn't there, and the error appears. Untick it, or press Load again, to load the books.
module Snippets.FetchJson
open Fable.Core
open Fetch
open Firelight
open Firelight.Task
open type Firelight.Lit
/// A book as the server sends it. Nothing checks that the JSON has these fields.
type Book =
abstract title: string
abstract author: string
abstract year: int
let getBooks (url: string) (signal: AbortSignal) =
promise {
// Fails for a response that isn't ok, such as a 404, as well as for no response.
let! response = fetch url [ Signal signal ]
return! response.json<Book[]> ()
}
let private bookList (books: Book[]) =
html $"""<ul>{books |> Array.map (fun b -> html $"<li><cite>{b.title}</cite>, {b.author} ({b.year})</li>")}</ul>"""
[<AttachMembers>]
type BookList() as this =
inherit LitElement()
/// Set when leaving the page cancelled a request, so coming back can make it again.
let mutable interrupted = false
let books =
LitTask(
this,
TaskConfig(
TaskFunction(fun (args: string[]) options ->
match args with
// U2.Case2, not !^: nothing here says if the task's result is the books or the promise.
| [| url |] -> U2.Case2(getBooks url options.signal)
// Never happens, as args holds one URL, but a match covers every length.
| _ -> initialState
),
// A relative URL, next to this page. Yours would be your API's address.
args = fun () -> [| if this.broken then "no-such-file.json" else "books.json" |]
)
)
static member properties =
PropertyDeclarations.create [ "broken", PropertyDeclaration<bool>(state = true) ]
static member styles =
css
$$"""
:host { display: grid; gap: 0.5rem; justify-items: start; }
p, ul { margin: 0; }
button { font: inherit; padding: 0.3rem 0.9rem; }
"""
member val broken = false with get, set
override this.disconnectedCallback() =
base.disconnectedCallback ()
// A task doesn't stop when its component leaves the page; abort the request ourselves.
if books.status = TaskStatus.PENDING then
interrupted <- true
books.abort ()
override this.connectedCallback() =
base.connectedCallback ()
if interrupted then
interrupted <- false
books.run () |> ignore
override this.render() =
let status =
StatusRenderer(
pending = (fun () -> html $"""<p role="status">Loading books…</p>"""),
complete = bookList,
error = fun e -> html $"""<p role="alert">The books didn't load. {(unbox<exn> e).Message}</p>"""
)
html
$"""
<label>
<input type="checkbox" .checked={this.broken} @change={Ev.checked' (fun on -> this.broken <- on)}>
Ask for a file that isn't there
</label>
<button @click={fun _ -> books.run () |> ignore}>Load again</button>
{books.render status}"""
defineElement<BookList> "my-book-list"
Like every LitTask demo, this one isn't prerendered: the task starts as the component first
updates in the browser, so its first render there shows "Loading" where the build's showed nothing.
How it works
The request uses fetch from Fable.Fetch, the
Fable binding for the browser's Fetch API. Firelight.Task depends on it, so
open Fetch is all the module needs.
- The request.
getBooksis apromise { }that awaits, withlet!, the promisesfetchandjsonreturn. Thepromisebuilder is Fable.Promise's, which also comes with Firelight.Task. The browser'sfetchonly fails when there's no response at all, and a 404 or a 500 arrives as an ordinary response; Fable.Fetch'sfetchalso fails for those, with a message such as "404 Not Found for URL …". UsefetchUnsafeto read such a response yourself. - The JSON.
Bookis an interface over the parsed objects, sob.titlecompiles to plain property access. It types the fields but checks nothing: if the server sends something else, the mistake shows up wherever a field is used. When you don't control the server, decode the JSON with a library such as Thoth.Json, as the Kanban sample does. - Cancelling.
LitTaskpasses each run anAbortSignal, Fable.Fetch's type, and the request passes it on withSignal signal. When the arguments change while a request is running, the task aborts the signal, and the browser drops the old request. - The states.
StatusRenderertakes a function for each status: a loading message, the list, or the error. Its error is anobj, since JavaScript can throw anything; here it's always theErrorthatfetchorjsonmade, sounbox<exn>reads its message. - Loading again.
books.run ()runs the task again with the same arguments, for an error that may not happen twice, such as a dropped connection. The button stays on the page in every state, so pressing it from the keyboard doesn't lose the focus. - Leaving the page. A task doesn't abort its run when its component is removed, so
disconnectedCallbackcallsbooks.abort ()if a request is still running. An element can be removed and added back, as when a list moves it, soconnectedCallbackruns the interrupted request again.
The address is relative, so the file is found next to this page. Point it at your API instead.
With Elmish
In an app with an Elmish loop, start the same request from a command instead, with
Cmd.OfPromise.either, and keep the loading and error states in the model.
App architecture: Loading fast shows
one.
Related
- Search as you type: a task that waits for the user to stop typing.
- Using JavaScript libraries: Typed bindings for
interfaces over JavaScript objects, such as
Book.