@delali/narsil/react wraps the 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.
import { createNarsilClient } from '@delali/narsil/client'
export const narsilClient = createNarsilClient({ url: '/search-api', maxResponseBytes: 1_048_576 })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.
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.
import { type ChangeEvent, type ReactElement, useCallback, useState } from 'react'
import { useQuery } from '@delali/narsil/react'
import { useQuery, useSuggest } from '@delali/narsil/react'
interface Article {
title: string
}
interface CompletionProps {
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 => {
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) ?? ''
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 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 covers the client that the hooks send through, its deadlines, and its failures.
- Errors covers the codes in a hook's
errorand whyisNarsilErroris the test to use.