Search as you type
Build a search box that waits for a pause in typing before it searches, and ignores the results of searches for text that has since changed.
Type "berry" at your usual speed. The status line counts the requests: one for the whole word, not one for each letter. Type slowly, with a pause after each letter, and each pause sends one.
module Snippets.DebouncedSearch
open System
open Fable.Core
open Fable.Core.JsInterop
open Fetch
open Firelight
open Firelight.Task
open type Firelight.Lit
let mutable private requests = 0
// Stands in for your search API: it fetches a file of every fruit, next to this page, and filters
// it here, where your server would do the filtering. The signal cancels the request.
let private search (query: string) (signal: AbortSignal) =
requests <- requests + 1
promise {
let url = $"fruits.json?q={Uri.EscapeDataString query}"
let! response = fetch url [ Signal signal ]
let! fruits = response.json<string[]> ()
return fruits |> Array.filter (fun f -> f.ToLower().Contains(query.ToLower())) |> List.ofArray
}
[<AttachMembers>]
type DebouncedSearch() as this =
inherit LitElement()
let matches =
LitTask(
this,
TaskConfig(
TaskFunction(fun (args: string[]) options ->
match args with
| [| text |] when text.Trim() <> "" ->
!^(promise {
// Each keystroke starts a new run and aborts the one before,
// so only a query left alone for 300 ms gets past this line.
do! Promise.sleep 300
options.signal.throwIfAborted ()
return! search (text.Trim()) options.signal
})
// An empty box, and any other args: no results, no request.
| _ -> !^[]
),
args = fun () -> [| this.query |]
)
)
static member properties =
PropertyDeclarations.create [ "query", PropertyDeclaration<string>(state = true) ]
static member styles =
css
$$"""
:host { display: grid; gap: 0.5rem; justify-items: start; }
input { font: inherit; padding: 0.3rem 0.5rem; width: 16rem; max-width: 100%; }
p, ul { margin: 0; }
"""
member val query = "" with get, set
override this.render() =
// The last results stay on screen while the next search runs.
let found = matches.value |> Option.defaultValue []
let message =
match matches.status with
| _ when this.query.Trim() = "" -> "Type to search for a fruit."
| TaskStatus.PENDING -> "Searching…"
| TaskStatus.ERROR -> "The search failed."
| _ -> $"{found.Length} found. Requests so far: {requests}."
html
$"""
<input type="search" aria-label="Search fruit" .value={this.query}
@input={Ev.value (fun q -> this.query <- q)}>
<p role="status">{message}</p>
<ul>{found |> List.map (fun f -> html $"<li>{f}</li>")}</ul>"""
defineElement<DebouncedSearch> "my-debounced-search"
The demo isn't prerendered: LitTask starts its first run as the component first updates in the
browser, so the first render there differs from the build's. See Prerendering: Opting a component
out.
How it works
A LitTask runs its task function each time its arguments change, here at each
keystroke. When a new run starts, the task aborts the signal it gave the run before, and it keeps
only the newest run's result.
That gives a debounce in two lines. The task function's promise { } waits 300 ms, with
do! Promise.sleep 300, then calls options.signal.throwIfAborted (). If another key was pressed in the meantime, a newer run has
aborted this one's signal, so it throws and never sends its request. Only a run that was left alone
for 300 ms gets through, and there's no timer to clear.
A task doesn't abort its run when the component leaves the page, so a search that was already waiting still goes out. That's harmless here; Fetch JSON aborts it, for requests that cost something.
The rest:
- An empty box gets an empty list straight away, as a value (
!^[]) rather than a promise, with no wait and no request. An empty list can only be the value, which settles the task's result type, so!^also knows the promise from it (Tasks explains).rendershows the prompt instead of a count while the box is empty. renderreadsmatches.statusfor the message andmatches.valuefor the list. The value keeps the last results while the next search runs, so the list doesn't flash empty at each keystroke.- The message is in a
role="status"paragraph that is always on the page, so screen readers announce the count without moving focus from the box. searchstands in for your API: it fetches a file of every fruit and filters it, where your server would filter. It passes the task's signal to Fable.Fetch'sfetchwithSignal signal, so a search that is already running is cancelled when the text changes. Fetch JSON explains the request.
Choose the delay by how expensive a search is: around 300 ms feels immediate when typing stops, and still saves most requests.
Related
- Firelight.Task: the package and its status renderer.
- Templates: Typed event handlers for
Ev.value.