---
title: "Search as you type"
description: "A Firelight search box that searches as you type, waiting for a pause before it sends a request and dropping requests for text that has since changed, with Firelight.Task."
---

# 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.

- [Lit docs: Async tasks](https://lit.dev/docs/data/task/)

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.

```fsharp
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"
```

```html
<my-debounced-search></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](https://roboz0r.github.io/Firelight/guides/prerendering/#opting-a-component-out).

## How it works

A [`LitTask`](https://roboz0r.github.io/Firelight/packages/task/) 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](https://roboz0r.github.io/Firelight/cookbook/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](https://roboz0r.github.io/Firelight/from-lit/tasks/#loading-when-arguments-change) explains).
  `render` shows the prompt instead of a count while the box is empty.
- `render` reads `matches.status` for the message and `matches.value` for 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.
- `search` stands 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's `fetch` with `Signal signal`,
  so a search that is already running is cancelled when the text changes.
  [Fetch JSON](https://roboz0r.github.io/Firelight/cookbook/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](https://roboz0r.github.io/Firelight/packages/task/): the package and its status renderer.
- [Templates: Typed event handlers](https://roboz0r.github.io/Firelight/guides/templates/#typed-event-handlers) for `Ev.value`.
