> Complete content index: https://narsil.sondelali.com/llms.txt

# React hooks

> Give a React tree one Narsil client, search as somebody types, offer completions under the box, and read documents, stats, and tasks through hooks that share their requests.

URL: https://narsil.sondelali.com/docs/react-hooks
Section: Clients
Version: 0.3 (@delali/narsil@0.3.0, latest)

`@delali/narsil/react` wraps the [client SDK](/docs/client-sdk) in hooks that re-render their component when the server answers, so a component can show a search result, a document, or a task without handling the request itself. Every hook under one provider shares its requests, so when two components pass the same arguments to the same hook, the provider sends one request and both components receive the one answer. You build a help-centre search box here, in `narsil-client.ts`, `App.tsx`, and `HelpSearch.tsx`, and then add completions under it. Each code block below holds a whole file, with green lines marking what each step adds and struck lines marking what it takes away.

## Give the tree one client

Every hook below a `NarsilProvider` sends its requests through the provider's client. Build the client once, in a module of its own, because rebuilding it during a render discards every answer that the hooks hold. Point the client at a path on the page's own origin, and have your server forward the requests under that path to Narsil with the key attached, so that the key stays off the page, as the client SDK page describes.

```ts title="narsil-client.ts"
import { createNarsilClient } from '@delali/narsil/client'

export const narsilClient = createNarsilClient({ url: '/search-api', maxResponseBytes: 1_048_576 })
```

```tsx title="App.tsx"
import type { ReactElement } from 'react'
import { NarsilProvider } from '@delali/narsil/react'
import { HelpSearch } from './HelpSearch'
import { narsilClient } from './narsil-client'

export const App = (): ReactElement => (
  <NarsilProvider client={narsilClient}>
    <HelpSearch />
  </NarsilProvider>
)
```

A hook outside any provider throws a `NarsilError` with `CONFIG_INVALID`, because it has no client to send through. With `maxResponseBytes`, the client stops reading an answer past one MiB, which protects the tab from an answer that grows without limit.

## Search as somebody types

`useQuery` takes the index name and the same parameters as `query`, which it sends again whenever they change. With `enabled: false`, the hook sends nothing while the box is empty. With `keepPreviousData`, it keeps the last hits on screen while the next answer loads, so the list stays steady between keystrokes.

```tsx title="HelpSearch.tsx"
import { type ChangeEvent, type ReactElement, useCallback, useState } from 'react'
import { useQuery } from '@delali/narsil/react'

interface Article {
  title: string
}

export const HelpSearch = (): ReactElement => {
  const [term, setTerm] = useState('')
  const handleTermChange = useCallback((event: ChangeEvent<HTMLInputElement>): void => {
    setTerm(event.target.value)
  }, [])
  const search = useQuery<Article>(
    'articles',
    { term, limit: 5, document: { include: ['title'] } },
    { enabled: term.trim().length > 0, keepPreviousData: true }
  )

  return (
    <section>
      <input type="search" value={term} onChange={handleTermChange} aria-label="Search the help centre" />
      {search.isLoading && <p>Searching</p>}
      {search.error && <p role="alert">{search.error.message}</p>}
      <ul>
        {search.data?.hits.map((hit) => (
          <li key={hit.id}>{hit.document?.title}</li>
        ))}
      </ul>
    </section>
  )
}
```

Once somebody types `refund`, the list shows 'How a refund reaches your bank' and 'Refunds in another currency'. `isLoading` is true while the hook waits for an answer and has none to show, which under `keepPreviousData` happens before the first answer alone. `isFetching`, however, is true during every request, a refresh included, so use it for a quieter indicator. `error` holds the `NarsilError` that the last request ended on, and the hook clears it at the next successful answer. The hook takes no abort signal, because the provider stops a request as soon as no mounted component uses its answer.

## Offer completions under the box

`useSuggest` completes a prefix from the terms that the index holds, with the most widely used first. The component below requests completions for the last word once that word has two letters. When somebody clicks a completion, the component puts it in place of that word. A search for `ref` matches no article, because a plain search matches whole words. However, the completions under the box include `refund`, so somebody can click it to search for `refund`, which matches both refund articles.

```tsx title="HelpSearch.tsx"
import { type ChangeEvent, type ReactElement, useCallback, useState } from 'react'
import { useQuery } from '@delali/narsil/react' // [!code --]
import { useQuery, useSuggest } from '@delali/narsil/react' // [!code ++]

interface Article {
  title: string
}

interface CompletionProps { // [!code ++:18]
  term: string
  onPick: (term: string) => void
}

const Completion = ({ term, onPick }: CompletionProps): ReactElement => {
  const handleClick = useCallback((): void => {
    onPick(term)
  }, [onPick, term])

  return (
    <li>
      <button type="button" onClick={handleClick}>
        {term}
      </button>
    </li>
  )
}

export const HelpSearch = (): ReactElement => {
  const [term, setTerm] = useState('')
  const handleTermChange = useCallback((event: ChangeEvent<HTMLInputElement>): void => {
    setTerm(event.target.value)
  }, [])
  const handlePick = useCallback((completion: string): void => { // [!code ++:3]
    setTerm((current) => current.replace(/\S*$/, completion))
  }, [])
  const search = useQuery<Article>(
    'articles',
    { term, limit: 5, document: { include: ['title'] } },
    { enabled: term.trim().length > 0, keepPreviousData: true }
  )
  const lastWord = term.split(/\s+/).at(-1) ?? '' // [!code ++:2]
  const completions = useSuggest('articles', { prefix: lastWord, limit: 3 }, { enabled: lastWord.length >= 2 })

  return (
    <section>
      <input type="search" value={term} onChange={handleTermChange} aria-label="Search the help centre" />
      <ul aria-label="Completions"> // [!code ++:5]
        {completions.data?.terms.map((entry) => (
          <Completion key={entry.term} term={entry.term} onPick={handlePick} />
        ))}
      </ul>
      {search.isLoading && <p>Searching</p>}
      {search.error && <p role="alert">{search.error.message}</p>}
      <ul>
        {search.data?.hits.map((hit) => (
          <li key={hit.id}>{hit.document?.title}</li>
        ))}
      </ul>
    </section>
  )
}
```

Each completion is a component of its own, so that its click handler holds its own term. The hook returns each completion in the spelling that the documents hold, because the engine records every spelling that the stemmer changes, as [full-text search](/docs/full-text-search) describes. With `refund dis` in the box, the completions include `dispatch` while the two refund articles stay on screen.

## Read documents, stats, and tasks

The other hooks follow the same pattern, and each one returns the answer of the matching client method:

| Hook | What it returns |
| --- | --- |
| `useDocument(indexName, docId)` | It returns one stored document, or `undefined` with no error for an id that the index lacks. A missing `docId` switches it off. |
| `useDocuments(indexName, params)` | It returns a page of documents in id order, as `listDocuments` does. |
| `usePreflight(indexName, params)` | It returns the number of hits that a search would match, without the hits. |
| `useIndexes()` and `useStats(indexName)` | They return the engine's indexes, and the counts and the memory estimate of one index. |
| `useTask(taskId)` and `useTasks()` | `useTask` polls one task record every 250 ms until the task finishes, and `useTasks` returns the list of tasks. |
| `useImport(indexName)` | It returns a `start` function that sends a corpus as a task, along with the progress, the result, and a `cancel` function. |

Every read hook takes `enabled`, `keepPreviousData`, `refreshIntervalMs`, `headers`, and `timeoutMs` as its last argument. A hook with `refreshIntervalMs` requests its answer again on that interval and pauses while the page is hidden, while `refresh()` requests it once more when you call it. The provider keeps each answer for two seconds after the last component that uses it unmounts, so when somebody navigates straight back, the component shows the same result with no second request. Set `keepAliveMs` on the provider to change that interval. `useImport` stops its polling when the component unmounts, while the server finishes the load, so follow the load again with `useTask` under the id that `start` returned.

## Where to go next

- [Client SDK](/docs/client-sdk) covers the client that the hooks send through, its deadlines, and its failures.
- [Errors](/docs/errors) covers the codes in a hook's `error` and why `isNarsilError` is the test to use.
