npm package: @headband/react
Description: React hooks and components for Headband search — an InstantSearch-compatible SDK
Latest version: 0.1.1

# @headband/react

React hooks and components for [Headband](https://headband.dev) search. Drop-in building blocks for building fast, faceted search UIs -- designed as an InstantSearch-compatible alternative that works with Headband's unified search API.

## Installation

```bash
npm install @headband/react
```

**Peer dependencies:** React 18+ and React DOM 18+.

## Quick Start

```tsx
import {
  headband,
  HeadbandProvider,
  SearchBox,
  Hits,
  RefinementList,
  Pagination,
  Stats,
} from "@headband/react";

const client = headband("https://your-headband-instance.com", "your-api-key");

function App() {
  return (
    <HeadbandProvider client={client} index="movies">
      <SearchBox placeholder="Search movies..." />
      <Stats />
      <div style={{ display: "flex", gap: "2rem" }}>
        <RefinementList attribute="genres" header="Genres" />
        <div>
          <Hits hitComponent={MovieHit} />
          <Pagination />
        </div>
      </div>
    </HeadbandProvider>
  );
}

function MovieHit({ hit }: { hit: any }) {
  return (
    <div>
      <h3>{hit.title}</h3>
      <p>{hit.overview}</p>
    </div>
  );
}
```

## Architecture

All state lives in `<HeadbandProvider>`, which holds the search query, refinements, pagination, and sort state. Child components read and write state through React hooks. Search requests are debounced (200ms) and stale responses are automatically discarded.

```
HeadbandProvider          -- owns all search state, executes searches
  |-- useSearchBox()      -- query input binding
  |-- useHits()           -- current result hits
  |-- useRefinementList() -- facet filtering
  |-- usePagination()     -- page navigation
  |-- useStats()          -- result count & timing
  |-- useSortBy()         -- sort order
  |-- useRange()          -- numeric range filters
  |-- useHighlight()      -- highlighted text extraction
  |-- useSearch()         -- full state + actions (escape hatch)
```

---

## Client

### `headband(host, apiKey)`

Creates a Headband API client.

```ts
import { headband } from "@headband/react";

const client = headband("https://search.example.com", "your-api-key");
```

| Param    | Type     | Description                       |
| -------- | -------- | --------------------------------- |
| `host`   | `string` | Headband instance URL             |
| `apiKey` | `string` | API key for authentication        |

Returns a `HeadbandClient` with `search()` and `multiSearch()` methods.

---

## Provider

### `<HeadbandProvider>`

Wraps your search UI and manages all search state.

```tsx
<HeadbandProvider
  client={client}
  index="products"
  initialQuery="shoes"
>
  {/* search components */}
</HeadbandProvider>
```

| Prop           | Type              | Default | Description                              |
| -------------- | ----------------- | ------- | ---------------------------------------- |
| `client`       | `HeadbandClient`  | --      | Client returned by `headband()`          |
| `index`        | `string`          | --      | Index to search                          |
| `initialQuery` | `string`          | `""`    | Starting query value                     |
| `children`     | `ReactNode`       | --      | Child components                         |

---

## Hooks

### `useSearchBox()`

Binds to the search input. Returns the current query plus setters.

```tsx
const { query, setQuery, clear } = useSearchBox();
```

| Return     | Type                       | Description                                    |
| ---------- | -------------------------- | ---------------------------------------------- |
| `query`    | `string`                   | Current query string                           |
| `setQuery` | `(query: string) => void`  | Update the query (resets pagination)           |
| `clear`    | `() => void`               | Clear the query to an empty string             |

---

### `useHits<T>()`

Returns the current search result hits with loading and error state.

```tsx
const { hits, isLoading, error } = useHits<Movie>();
```

| Return      | Type          | Description                                    |
| ----------- | ------------- | ---------------------------------------------- |
| `hits`      | `Hit<T>[]`    | Array of hit objects                           |
| `isLoading` | `boolean`     | Whether a search is in progress                |
| `error`     | `Error\|null` | Error from the last search, if any             |

Each `Hit<T>` includes the original document fields, an optional `_formatted` object with highlighted values, and a typed `__data: T` accessor.

---

### `useRefinementList(props)`

Provides facet filtering for a given attribute. Automatically registers the attribute so the provider includes it in search requests.

```tsx
const { items, refine, canToggle, isLoading } = useRefinementList({
  attribute: "brand",
  limit: 10,
  sortBy: "count",
});
```

| Prop        | Type                 | Default   | Description                          |
| ----------- | -------------------- | --------- | ------------------------------------ |
| `attribute` | `string`             | --        | Facet attribute (e.g. `"genres"`)    |
| `limit`     | `number`             | `20`      | Max facet values to show             |
| `sortBy`    | `"count" \| "alpha"` | `"count"` | Sort order for facet values          |

| Return      | Type                     | Description                           |
| ----------- | ------------------------ | ------------------------------------- |
| `items`     | `RefinementListItem[]`   | Facet values with count & state       |
| `refine`    | `(value: string) => void`| Toggle a facet value on/off           |
| `canToggle` | `boolean`                | Whether any values are available      |
| `isLoading` | `boolean`                | Loading state                         |

Each `RefinementListItem` has `{ value: string; count: number; isRefined: boolean }`.

---

### `usePagination()`

Pagination state derived from the current search results. Pages are 0-indexed.

```tsx
const { currentPage, totalPages, totalHits, setPage, canPrevious, canNext, pages } =
  usePagination();
```

| Return        | Type                      | Description                          |
| ------------- | ------------------------- | ------------------------------------ |
| `currentPage` | `number`                  | Current page index (0-based)         |
| `totalPages`  | `number`                  | Total number of pages                |
| `totalHits`   | `number`                  | Total result count                   |
| `setPage`     | `(page: number) => void`  | Navigate to a page (clamped)         |
| `canPrevious` | `boolean`                 | Whether a previous page exists       |
| `canNext`     | `boolean`                 | Whether a next page exists           |
| `pages`       | `number[]`                | Window of page numbers for rendering |

---

### `useStats()`

Returns summary statistics about the current search.

```tsx
const { totalHits, processingTimeMs, query, isLoading } = useStats();
```

| Return            | Type      | Description                      |
| ----------------- | --------- | -------------------------------- |
| `totalHits`       | `number`  | Total results for the query      |
| `processingTimeMs`| `number`  | Server-side processing time (ms) |
| `query`           | `string`  | The query that produced results  |
| `isLoading`       | `boolean` | Loading state                    |

---

### `useSortBy(props)`

Manages sort-by dropdown state.

```tsx
const { currentSort, setSort, items } = useSortBy({
  items: [
    { value: "price:asc", label: "Price (low to high)" },
    { value: "price:desc", label: "Price (high to low)" },
    { value: "rating:desc", label: "Top rated" },
  ],
});
```

| Prop    | Type           | Description                               |
| ------- | -------------- | ----------------------------------------- |
| `items` | `SortByItem[]` | Available sort options                    |

Each `SortByItem` has `{ value: string; label: string }`.

| Return        | Type                              | Description                    |
| ------------- | --------------------------------- | ------------------------------ |
| `currentSort` | `string \| null`                  | Active sort value (null = relevance) |
| `setSort`     | `(value: string\|null) => void`   | Set the sort order             |
| `items`       | `(SortByItem & { isSelected })[]` | Items with `isSelected` flag   |

---

### `useRange(props)`

Numeric range filter for a given attribute.

```tsx
const { min, max, stats, setRange, clear, isLoading } = useRange({
  attribute: "price",
});
```

| Prop        | Type     | Description                           |
| ----------- | -------- | ------------------------------------- |
| `attribute` | `string` | Numeric attribute (e.g. `"price"`)    |

| Return      | Type                                       | Description                      |
| ----------- | ------------------------------------------ | -------------------------------- |
| `min`       | `number \| undefined`                      | Current min bound                |
| `max`       | `number \| undefined`                      | Current max bound                |
| `stats`     | `{ min: number; max: number } \| undefined`| Overall min/max from engine      |
| `setRange`  | `(range: { min?: number; max?: number }) => void` | Set range filter        |
| `clear`     | `() => void`                               | Remove the range filter          |
| `isLoading` | `boolean`                                  | Loading state                    |

---

### `useHighlight(props)`

Extracts the highlighted value for a specific attribute from a hit. Sanitizes the HTML to only allow `<em>` tags for safety.

```tsx
const { value, highlighted } = useHighlight({
  hit: someHit,
  attribute: "title",
});
```

| Prop        | Type                     | Description                        |
| ----------- | ------------------------ | ---------------------------------- |
| `hit`       | `Record<string, unknown>`| The hit object from `useHits()`    |
| `attribute` | `string`                 | Attribute to highlight             |

| Return        | Type      | Description                                    |
| ------------- | --------- | ---------------------------------------------- |
| `value`       | `string`  | Highlighted HTML string (safe for innerHTML)   |
| `highlighted` | `boolean` | Whether the value contains highlight marks     |

Supports nested attributes via dot notation (e.g. `"author.name"`).

---

### `useSearch()`

Escape-hatch hook that exposes the full search state and all actions. Prefer the specialized hooks above for most use cases.

```tsx
const {
  query, results, isLoading, error, refinements, ranges, sortBy, page,
  hitsPerPage, facets, setQuery, setPage, setSortBy, setHitsPerPage,
  toggleRefinement, setRange, clearRange, registerFacet, unregisterFacet,
  refresh,
} = useSearch();
```

---

## Components

All components accept a `styled` prop (default `true`) that toggles built-in Tailwind CSS classes. Set `styled={false}` to bring your own styles.

### `<SearchBox>`

Search input with built-in clear button and search icon.

```tsx
<SearchBox
  placeholder="Search products..."
  autoFocus
  onQueryChange={(q) => console.log(q)}
/>
```

| Prop            | Type                        | Default        | Description                  |
| --------------- | --------------------------- | -------------- | ---------------------------- |
| `placeholder`   | `string`                    | `"Search..."`  | Input placeholder            |
| `className`     | `string`                    | --             | Additional CSS classes        |
| `autoFocus`     | `boolean`                   | `false`        | Auto-focus on mount          |
| `onQueryChange` | `(query: string) => void`   | --             | Called on every query change  |
| `styled`        | `boolean`                   | `true`         | Enable built-in styles       |

---

### `<Hits>`

Renders search result hits in a grid layout.

```tsx
<Hits hitComponent={ProductCard} emptyComponent={<p>No results.</p>} />
```

| Prop             | Type                            | Default        | Description                    |
| ---------------- | ------------------------------- | -------------- | ------------------------------ |
| `hitComponent`   | `ComponentType<{ hit: Hit }>`   | JSON card      | Custom component per hit       |
| `className`      | `string`                        | --             | CSS classes for the container  |
| `emptyComponent` | `ReactNode`                     | "No results"   | Shown when there are no hits   |
| `styled`         | `boolean`                       | `true`         | Enable built-in styles         |

---

### `<RefinementList>`

Checkbox list of facet values with counts.

```tsx
<RefinementList attribute="brand" header="Brand" limit={10} sortBy="count" />
```

| Prop        | Type                 | Default   | Description                      |
| ----------- | -------------------- | --------- | -------------------------------- |
| `attribute` | `string`             | --        | Facet attribute                  |
| `limit`     | `number`             | `20`      | Max values to show               |
| `sortBy`    | `"count" \| "alpha"` | `"count"` | Value sort order                 |
| `header`    | `string`             | --        | Optional heading above the list  |
| `className` | `string`             | --        | Additional CSS classes           |
| `styled`    | `boolean`            | `true`    | Enable built-in styles           |

---

### `<Pagination>`

Page navigation with Previous/Next buttons and numbered pages.

```tsx
<Pagination />
```

| Prop        | Type      | Default | Description              |
| ----------- | --------- | ------- | ------------------------ |
| `className` | `string`  | --      | Additional CSS classes   |
| `styled`    | `boolean` | `true`  | Enable built-in styles   |

---

### `<Stats>`

Displays result count and processing time (e.g. "1,234 results in 3ms").

```tsx
<Stats />
<Stats render={({ totalHits, processingTimeMs }) =>
  `Found ${totalHits} items (${processingTimeMs}ms)`
} />
```

| Prop        | Type                                                    | Default | Description               |
| ----------- | ------------------------------------------------------- | ------- | ------------------------- |
| `render`    | `(stats: { totalHits; processingTimeMs; query }) => string` | --  | Custom render function    |
| `className` | `string`                                                | --      | Additional CSS classes    |
| `styled`    | `boolean`                                               | `true`  | Enable built-in styles    |

---

### `<SortBy>`

Sort-by select dropdown.

```tsx
<SortBy
  items={[
    { value: "price:asc", label: "Price (low to high)" },
    { value: "price:desc", label: "Price (high to low)" },
  ]}
/>
```

| Prop        | Type           | Default     | Description                |
| ----------- | -------------- | ----------- | -------------------------- |
| `items`     | `SortByItem[]` | --          | Sort options               |
| `label`     | `string`       | `"Sort by"` | Accessibility label        |
| `className` | `string`       | --          | Additional CSS classes     |
| `styled`    | `boolean`      | `true`      | Enable built-in styles     |

---

### `<Highlight>`

Renders highlighted text for a hit attribute. Safe for use with `dangerouslySetInnerHTML` -- all HTML except `<em>` tags is stripped and escaped.

```tsx
<Highlight hit={hit} attribute="title" />
```

| Prop        | Type                      | Default  | Description                   |
| ----------- | ------------------------- | -------- | ----------------------------- |
| `hit`       | `Record<string, unknown>` | --       | Hit object from `useHits()`   |
| `attribute` | `string`                  | --       | Attribute to highlight        |
| `tagName`   | `keyof HTMLElementTagNameMap` | `"span"` | Wrapper element tag        |
| `className` | `string`                  | --       | Additional CSS classes        |
| `styled`    | `boolean`                 | `true`   | Enable built-in styles        |

---

### `<RangeInput>`

Min/max number inputs for numeric range filtering.

```tsx
<RangeInput attribute="price" header="Price" />
```

| Prop             | Type      | Default | Description                   |
| ---------------- | --------- | ------- | ----------------------------- |
| `attribute`      | `string`  | --      | Numeric attribute to filter   |
| `header`         | `string`  | --      | Optional heading              |
| `minPlaceholder` | `string`  | `"Min"` | Min input placeholder         |
| `maxPlaceholder` | `string`  | `"Max"` | Max input placeholder         |
| `className`      | `string`  | --      | Additional CSS classes        |
| `styled`         | `boolean` | `true`  | Enable built-in styles        |

---

### `<PoweredBy>`

"Powered by Headband" badge with link.

```tsx
<PoweredBy />
```

| Prop        | Type      | Default                  | Description              |
| ----------- | --------- | ------------------------ | ------------------------ |
| `href`      | `string`  | `"https://headband.dev"` | Link URL                 |
| `className` | `string`  | --                       | Additional CSS classes   |
| `styled`    | `boolean` | `true`                   | Enable built-in styles   |

---

## Comparison to Algolia InstantSearch

| Feature               | @headband/react              | react-instantsearch          |
| --------------------- | ---------------------------- | ---------------------------- |
| Provider              | `<HeadbandProvider>`         | `<InstantSearch>`            |
| Search input          | `<SearchBox>` / `useSearchBox` | `<SearchBox>` / `useSearchBox` |
| Results               | `<Hits>` / `useHits`        | `<Hits>` / `useHits`        |
| Facets                | `<RefinementList>`           | `<RefinementList>`           |
| Pagination            | `<Pagination>`               | `<Pagination>`               |
| Stats                 | `<Stats>`                    | `<Stats>`                    |
| Sort                  | `<SortBy>`                   | `<SortBy>`                   |
| Range                 | `<RangeInput>` / `useRange`  | `<RangeInput>` / `useRange`  |
| Highlighting          | `<Highlight>`                | `<Highlight>`                |
| Bundle size           | ~33 KB (ESM, unminified)     | ~120 KB+                     |
| Backend lock-in       | None (Headband is engine-agnostic) | Algolia only          |
| Built-in styles       | Tailwind (opt-out with `styled={false}`) | CSS classes       |

## TypeScript

All hooks, components, and types are fully typed and exported. Import types directly:

```ts
import type {
  HeadbandClient,
  SearchParams,
  SearchResponse,
  Hit,
  RefinementListItem,
  SortByItem,
} from "@headband/react";
```

## License

MIT