# React

> @findlane/browser/react: a search box that suggests as visitors type, a results page with filters, and the hooks behind them for your own markup.

## Install

The React hooks and components come with the browser SDK, under `@findlane/browser/react`. They work with React 18.2 and 19.

```sh
npm install @findlane/browser
```

Pages that don't import `@findlane/browser/react` don't load it, and the browser SDK itself doesn't need React.

## A search box

`SearchBox` suggests results as visitors type, with the arrow keys, Enter, and Escape working as screen readers expect. It needs only the index's public ID:

```tsx
import { SearchBox } from "@findlane/browser/react"
import "@findlane/browser/react/styles.css"

export function Header() {
  return (
    <SearchBox
      publicId="pub_your_public_id"
      placeholder="Search products"
      fields={["title", "image", "url"]}
      getHref={(hit) => hit.record.url}
      action="/search"
    />
  )
}
```

Each suggestion shows the record's `image` and `title` (or `name`), with the matched words marked. Pass `renderHit` to show something else.

| Prop                   |                                                                                      |
| ---------------------- | ------------------------------------------------------------------------------------ |
| `publicId`             | The index's public ID. Or set it once with `FindlaneProvider`.                       |
| `fields`               | The record fields to return. Fewer fields make responses smaller.                    |
| `getHref`              | A hit's page. Clicking a suggestion, or Enter on one, goes there.                    |
| `hrefTemplate`         | A hit's page as a template, such as `"/products/{handle}"`, for server components.   |
| `onSelect`             | Called with the chosen hit instead of following its link, for client-side routers.   |
| `action`               | A results page, such as `/search`. Enter without a suggestion goes there with `?q=`. |
| `onSubmit`             | Called with the query on Enter without a suggestion, instead of `action`.            |
| `renderHit`            | A suggestion's content.                                                              |
| `limit`                | Suggestions to show: 6 by default.                                                   |
| `filters`, `sort`      | As in [Search](/docs/search.md), for every suggestion.                                  |
| `minLength`            | Characters to type before searching: 1 by default.                                   |
| `debounceMs`           | How long to wait for a pause in typing: 150 ms by default.                           |
| `placeholder`, `label` | The input's placeholder, and its accessible name ("Search" by default).              |
| `empty`, `seeAll`      | The "No results" and "See all N results" text.                                       |

In a server component, such as a Next.js page, props must be plain values, so use `hrefTemplate` instead of `getHref`. `{handle}` puts in the record's `handle`, URL-encoded, and `{objectID}` the hit's ID. A template that is a single field, such as `"{url}"`, uses that field's URL as it is. Only `http:`, `https:`, and relative links are allowed.

## A search dialog

`SearchDialog` is a search button that opens a dialog with the same search box, as on docs sites. ⌘K (Ctrl+K elsewhere) and `/` open it too:

```tsx
<SearchDialog publicId="pub_your_public_id" hrefTemplate="{url}" fields={["title", "url"]} />
```

It takes the same props as `SearchBox`, plus `trigger` for the button's content, `footer` for a line at the bottom of the open dialog, such as keyboard hints, and `shortcuts={false}` to turn the keys off. It uses the browser's `<dialog>`, so focus stays inside it while it's open. Escape or a click outside closes it.

## Styles

The components render plain elements with `data-findlane-*` attributes and no styles of their own. `@findlane/browser/react/styles.css` gives them a default look that follows the page's `color-scheme`. Its rules are inside `:where()`, so your own CSS wins without `!important`. To theme it, set these variables anywhere above the components:

```css
.site-header {
  --findlane-accent: #0f766e;
  --findlane-radius: 6px;
  --findlane-background: #fff;
  --findlane-border: #d4d4d8;
  --findlane-muted: #71717a;
}
```

## Your own markup

`useAutocomplete` keeps the search box's state and gives your elements their attributes and events, so the [ARIA combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) works with any design:

```tsx
import { Highlight, useAutocomplete } from "@findlane/browser/react"

function Search() {
  const box = useAutocomplete<Product>({
    fields: ["title", "url"],
    onSelect: (hit) => navigate(hit.record.url),
  })
  return (
    <div className="search">
      <input {...box.getInputProps({ placeholder: "Search", "aria-label": "Search" })} />
      {box.isOpen && (
        <ul {...box.getListboxProps()}>
          {box.hits.map((hit, index) => (
            <li key={hit.id} {...box.getOptionProps(index)}>
              <Highlight hit={hit} field="title" />
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}
```

It takes the same options as `SearchBox` and returns `query`, `hits`, `total`, `error`, `isPending`, `isOpen`, and `activeIndex`. A new keystroke cancels the search in flight, and a slow response never replaces a newer one.

## A results page

`SearchProvider` holds a results page's search. The components and hooks inside it share its query, filters, and results, wherever they are in the page:

```tsx
import {
  CurrentRefinements,
  Hits,
  LoadMore,
  RefinementList,
  SearchInput,
  SearchProvider,
  SortSelect,
  Stats,
} from "@findlane/browser/react"

export function SearchPage() {
  return (
    <SearchProvider
      publicId="pub_your_public_id"
      facets={["brand", "category"]}
      pageSize={24}
      routing
    >
      <SearchInput placeholder="Search products" />
      <aside>
        <RefinementList field="brand" label="Brand" />
        <RefinementList field="category" label="Category" />
      </aside>
      <main>
        <Stats />
        <SortSelect
          options={[
            { id: "relevance", label: "Most relevant" },
            { id: "price_asc", label: "Price: low to high" },
          ]}
        />
        <CurrentRefinements />
        <Hits<Product> render={(hit) => <ProductCard hit={hit} />} />
        <LoadMore />
      </main>
    </SearchProvider>
  )
}
```

When a visitor picks a brand, the other brands stay listed with their counts, so more can be added. That's still one search per change: the provider asks for [`disjunctiveFacets`](/docs/search.md#facets).

| Prop                     |                                                                                                        |
| ------------------------ | ------------------------------------------------------------------------------------------------------ |
| `facets`                 | Fields to count values for, which `RefinementList` and `useRefinementList` choose from.                |
| `filterFields`           | Fields that `useRange` and `useToggle` filter, so the URL can keep them.                               |
| `facetStats`             | Number fields whose smallest and largest values `useRange` returns, such as a price slider's bounds.   |
| `filters`                | Filters always applied, such as `{ published: true }`. They aren't shown as choices.                   |
| `near`                   | Where searches are near until the visitor picks another place, such as `{ visitor: true }`; see [Location](#location). |
| `pageSize`               | Hits per page: 20 by default, up to 50.                                                                |
| `fields`                 | The record fields to return.                                                                           |
| `countMode`, `facetMode` | `"exact"` by default, so totals and counts cover every match. `"page"` is faster on large indexes.     |
| `routing`                | Keep the search in the page's URL, as `?q=shoe&brand=Acme&page=2`, so it can be shared and Back works. |
| `state`, `onStateChange` | Sync the search with your router instead of `routing`.                                                 |
| `initialResults`         | Results from the server; see [Server rendering](#server-rendering).                                    |

With `routing`, typing replaces the current history entry, and choosing a filter adds one, so Back undoes a filter rather than a keystroke. Other parameters in the URL are kept.

### Hooks

Every component is built on a hook, for when you want your own markup:

| Hook                       | Returns                                                                             |
| -------------------------- | ----------------------------------------------------------------------------------- |
| `useSearchBox()`           | `query`, `setQuery`, `clear`, `isPending`.                                          |
| `useHits()`                | `hits`, `total`, `response`, `request`, `ms`, `isLoaded`, `isPending`, `error`.     |
| `useRefinementList(field)` | `items` (`value`, `count`, `selected`), `toggle(value)`, `clear`.                   |
| `useRange(field)`          | `range` (`{ gte, lte }`), `min`, `max`, `setRange`, `clear`, for numbers and dates. |
| `useToggle(field, value)`  | `on`, `set`, `toggle`, for a filter like `inStock: true`.                           |
| `useSortBy()`              | `sort`, `setSort`.                                                                  |
| `useCurrentRefinements()`  | `items` (`field`, `value`, `label`, `remove`), `clear`.                             |
| `useLoadMore()`            | `hasMore`, `loadMore`, `isLoading`, `shown`, `total`.                               |
| `usePagination()`          | `page` (from 0), `pages`, `setPage`, `hasPrevious`, `hasNext`.                      |
| `useDidYouMean()`          | `didYouMean` (`q`, `applied`, `changes`) and `apply`; see below.                    |
| `useGeoSearch()`           | `near`, `within`, `center`, `setNear`, `nearVisitor`, `setWithin`, `clear`; see below. |
| `useSearchState()`         | The whole `state` and `setState`.                                                   |

A price range with two inputs, for example:

```tsx
function PriceRange() {
  const { range, setRange } = useRange("price")
  const bound = (value: string) => (value ? Number(value) : undefined)
  return (
    <fieldset>
      <legend>Price</legend>
      <input
        type="number"
        placeholder="Min"
        value={range?.gte ?? ""}
        onChange={(event) => setRange({ ...range, gte: bound(event.target.value) })}
      />
      <input
        type="number"
        placeholder="Max"
        value={range?.lte ?? ""}
        onChange={(event) => setRange({ ...range, lte: bound(event.target.value) })}
      />
    </fieldset>
  )
}
```

For a slider, list the field in the provider's `facetStats` too: `min` and `max` are its smallest and largest values across the matches, without the range itself, so the slider's ends stay put while it moves.

The [playground](/playground) is built this way.

### Did you mean

With the index's [`didYouMean` setting](/docs/settings.md#did-you-mean) on, `DidYouMean` says "Showing results for protein" when typo tolerance corrected the query. When nothing matches, it waits until the visitor stops typing, asks for a suggestion, and shows "Did you mean protein?" as a button that searches for it:

```tsx
<SearchInput />
<DidYouMean />
<Hits render={(hit) => <ProductCard hit={hit} />} />
```

It shows nothing for indexes without the setting, and doesn't ask them. `delay` sets the pause (400 ms by default), and `format` renders your own line: `format={(didYouMean, apply) => ...}`.

### Location

With the index's [location field](/docs/settings.md#location), `useGeoSearch` searches [by location](/docs/search.md#location). Pass `near={{ visitor: true }}` to the provider to start nearest the visitor:

```tsx
<SearchProvider publicId="pub_…" near={{ visitor: true }} routing>
  <StoreFinder />
</SearchProvider>

function StoreFinder() {
  const { hits } = useHits<Store>()
  const { center, setNear, setWithin } = useGeoSearch()
  // center: where distances are from, such as the visitor's city, to center a map on.
  return hits.map((hit) => (
    <p key={hit.id}>
      {hit.record.name}: {(hit.distance! / 1000).toFixed(1)} km
    </p>
  ))
}
```

- **`setNear({ lat, lng, radius })`** searches near a point, and `nearVisitor(radius)` near the visitor.
- **`setWithin({ north, south, east, west })`** keeps what a map shows. Call it from the map's move events: map moves replace the history entry instead of adding one, so Back doesn't step through every drag.
- **`clear()`** removes both.
- **With `routing`,** the URL keeps them as `near=51.5074,-0.1278` (or `near=visitor`) and `within=north,east,south,west`.

There's no map component: use any map library. The [map playground](/playground/places) uses MapLibre.

## Highlights

`Highlight` shows a field with the words the query matched in `<mark>`. `Snippet` cuts a long field, such as a description, to the words around its best match:

```tsx
<Highlight hit={hit} field="title" />
<Snippet hit={hit} field="description" words={24} />
```

Both render React elements, not HTML, so they work under a strict Content Security Policy. A field without a match shows its plain value. `field` can be a path such as `brand.name`, and a list field's values are joined with `separator` (", " by default). Pass `mark="strong"` or your own component to change the element.

## Server rendering

Browser search answers only browsers on the index's allowed origins, so a server renders the first results with the [server SDK](/docs/server-sdk.md) and a key with the `search` scope. `getServerResults` runs the same search the provider would, from `@findlane/browser/react/ssr`, which server code can import. A Next.js page:

```tsx
// app/search/page.tsx
import { SearchServerClient } from "@findlane/server"
import { getServerResults, stateFromUrl } from "@findlane/browser/react/ssr"
import { SearchPage } from "./search-page" // a "use client" component with the SearchProvider

const config = { facets: ["brand", "category"], pageSize: 24 }
const products = new SearchServerClient({
  workspaceId: process.env.FINDLANE_WORKSPACE_ID!,
  apiKey: process.env.FINDLANE_SEARCH_KEY!,
}).index("products")

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[]>>
}) {
  const state = stateFromUrl(await searchParams, config)
  const initialResults = await getServerResults(config, state, (request) =>
    products.search(request)
  )
  return <SearchPage config={config} initialResults={initialResults} />
}
```

Give the client's `SearchProvider` the same config, `initialResults`, and `routing`. Its first render then matches the server's and doesn't search again. Searches from the server count toward your plan like browser searches.

## One search

`useSearch` keeps a single search current, for anything the components don't cover. It waits for a pause in typing, cancels the search in flight, and keeps the previous results while the next ones load. Pass `null` to not search.

```tsx
const { data, error, isPending } = useSearch<Product>(query ? { q: query, limit: 5 } : null, {
  publicId: "pub_your_public_id",
})
```

Recent responses are kept in memory for five minutes, so going back to a query shows its results at once and doesn't search again.

## Errors

The hooks return a failed search's `FindlaneError` as `error`; see [Errors](/docs/browser-sdk.md#errors). `messageFor(error)` turns it into a sentence for visitors. A 403 (an origin that isn't allowed) or 402 (a plan's searches used up) becomes "Search isn't available right now.", since visitors can't fix either.
