Guides / Building Search UI / Widgets / Create your own widgets

Create Your Own React InstantSearch Hooks Widgets

React InstantSearch Hooks comes with multiple widgets that you can extensively customize, notably using Hooks to completely change the rendering.

If a widget and its Hook don’t cover your needs, you can create your own widget. Making widgets is the most advanced way of customizing your search experience and it requires a deeper knowledge of InstantSearch and Algolia.

This guide covers how to build a custom widget for React InstantSearch Hooks:

You’re trying to create your own InstantSearch widget and that’s awesome, but it also means that you couldn’t find what you were looking for. InstantSearch aims at offering the best out-of-the-box experience, so we’d love to hear about your use case.

Don’t hesitate to open a feature request explaining what you’re trying to achieve.

When to create custom widgets

You can create a new widget when none of the existing widgets fit your functional needs. However, if you’re trying to redefine the UI or DOM output of a widget, you should, instead, extend it by using its Hook counterpart.

Existing widgets and Hooks should fit most of your use cases and you should look into them before creating custom connectors. For example, to create buttons that set predefined queries, you could use useSearchBox(). Although you’re not rendering a search box, the connector provides the necessary APIs for this, so there’s no need to re-develop it.

For help, explain your situation and ask questions on GitHub.

You’ll see references to InstantSearch.js and its APIs throughout the guide. React InstantSearch Hooks relies on this core library and bridges them to React with a thin adapter. Once you’re done building an InstantSearch.js connector, you’ll turn it into a Hook.

If you’re using TypeScript, install algoliasearch-helper and instantsearch.js as development dependencies to access the necessary types. Make sure to use the same versions as the ones in your React InstantSearch Hooks version.

Building a custom connector

When creating a custom widget, start by writing a connector that encapsulates all the logic of your widget, yet keeps the rendering separate. Since you’re building a widget for React InstantSearch Hooks, this lets you turn the connector into a Hook later on and handle the rendering with a React component.

This guide uses the example of a negative refinement list widget. It’s similar to a <RefinementList>, but instead of filtering on the selected items, it excludes them from the search. For example, selecting the brand “Apple” would filter results to all matching records that aren’t Apple products.

Negative refinement list custom widget

Negative refinement list custom widget

Write the connector function

First, create a connectNegativeRefinementList function that takes a render and an unmount function. It should return a negativeRefinementList function (the widget factory) that takes widget parameters and returns an object (the widget).

For the sake of simplicity, the only parameter that the widget accepts is attribute. This lets users specify which record attribute to filter on.

1
2
3
4
5
6
7
8
9
export function connectNegativeRefinementList(renderFn, unmountFn = noop) {
  return function negativeRefinementList({ attribute }) {
    return {
      // …
    };
  };
}

const noop = () => {};

Your custom connector needs an identifier. The naming convention is "myOrganization.myWidget" (for example, "microsoft.negativeRefinementList"). If you don’t have an organization, you can use your name.

1
2
3
4
5
6
7
8
9
export function connectNegativeRefinementList(renderFn, unmountFn = noop) {
  return function negativeRefinementList({ attribute }) {
    return {
      $$type: 'myOrganization.negativeRefinementList',
    };
  };
}

// …

Compute the render state

For now, the widget isn’t doing much. The whole point of writing a widget is to hook into the InstantSearch lifecycle to alter the search call with new parameters, pick data from the search response, and expose it for the user to render it the way they want.

To do so, you need to implement the getWidgetRenderState method. It should return an object with the data and APIs you want to expose to the render function.

For the negative refinement list, you need to expose:

  • The items to display in the list.
  • A refine function to trigger a new search from the UI with new items to exclude.

The widget parameters are also passed under the widgetParams key. This is necessary for internal purposes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
export function connectNegativeRefinementList(renderFn, unmountFn = noop) {
  return function negativeRefinementList(widgetParams) {
    const { attribute } = widgetParams;

    const connectorState = {};

    return {
      // …
      getWidgetRenderState({ results, helper }) {
        // To ensure `refine` keeps the same reference across renders, create
        // and store it once outside the method scope.
        if (!connectorState.refine) {
          connectorState.refine = (value) =>
            helper.toggleFacetExclusion(attribute, value).search();
        }

        // When there are no results, return the API with default values.
        // It's helpful to render a default UI until results are available.
        if (!results) {
          return { items: [], refine: connectorState.refine, widgetParams };
        }

        // Retrieve facet values from the results for the given attribute
        // and sort them by ascending name.
        // Later on, you could let users pass a `sortBy` parameter.
        const items = results.getFacetValues(attribute, {
          sortBy: ['name:asc'],
        }) || [];

        return {
          items,
          // A function to toggle a value when selected.
          // If the value is already excluded, the exclusion is unset.
          // Otherwise, it's added to the exclusion list.
          // Then, a search is triggered.
          refine: connectorState.refine,
          widgetParams,
        };
      },
    };
  };
}

// …

In InstantSearch, each widget you add registers its render state in one global object. You need to specify how to store your widget render state in this global tree by implementing the getRenderState method.

You might use multiple negative refinement lists in your application but with different attributes—for example, you might want to exclude by brand and by categories. Here, you want to store each widget’s render state individually so they don’t override each other.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export function connectNegativeRefinementList(renderFn, unmountFn = noop) {
  return function negativeRefinementList(widgetParams) {
    const { attribute } = widgetParams;

    return {
      // …
      getRenderState(renderState, renderOptions) {
        // The global render state is merged with a new one to store the render
        // state of the current widget.
        return {
          ...renderState,
          negativeRefinementList: {
            ...renderState.negativeRefinementList,
            // You can use multiple `negativeRefinementList` widgets in a single
            // app so you need to register each of them separately.
            // Each `negativeRefinementList` widget's render state is stored
            // by the `attribute` it impacts.
            [attribute]: this.getWidgetRenderState(renderOptions),
          },
        };
      },
    };
  };
}

// …

Set up the lifecycle

When you add InstantSearch widgets to your app, they go through several steps in response to internal events. These steps are the InstantSearch lifecycle.

You must register lifecycle hooks on your widget to run code at the init, render, and dispose stages. Use these functions to call the user-provided render and unmount functions with the correct information.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
export function connectNegativeRefinementList(renderFn, unmountFn = noop) {
  return function negativeRefinementList({ attribute }) {
    return {
      // …

      // The `init` step runs once when the app starts, before the first
      // search. It's useful to first render the UI with some default state.
      init(initOptions) {
        const { instantSearchInstance } = initOptions;

        renderFn(
          // The render state is the data provided to the render function,
          // necessary to build the UI.
          {
            ...this.getWidgetRenderState(initOptions),
            instantSearchInstance,
          },
          // Calling the function with `isFirstRender=true` lets you perform
          // conditional logic in the render function.
          true
        );
      },
      // The `render` step runs whenever new results come back from Algolia.
      // It's useful to react to changes, for example, re-rendering the UI
      // with new information.
      render(renderOptions) {
        const { instantSearchInstance } = renderOptions;

        renderFn(
          // The render state is the data provided to the render function,
          // necessary to build the UI.
          {
            ...this.getWidgetRenderState(renderOptions),
            instantSearchInstance,
          },
          // Calling the function with `isFirstRender=false` lets you perform
          // conditional logic in the render function.
          false
        );
      },
      // The `dispose` step runs when removing the widget. It's useful to
      // clean up anything that the widget created during its lifetime:
      // search parameter, UI, events, etc.
      dispose(disposeOptions) {
        unmountFn();
      },
    };
  };
}

// …

Interact with routing

An important aspect of building an InstantSearch widget is how to make it work with routing. Your custom widget should be able to synchronize its state with the browser URL so you can share a link to your search experience in any given state.

In InstantSearch, routing uses an internal uiState object to derive the route. As with the render state, you need to specify how to store your widget UI state in the global UI state by implementing the getWidgetUiState method.

As with getRenderState, since you might use the widget multiple times with different attributes, you need to store each widget’s UI state individually so they don’t override each other.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export function connectNegativeRefinementList(renderFn, unmountFn = noop) {
  return function negativeRefinementList(widgetParams) {
    const { attribute } = widgetParams;

    return {
      // …
      getWidgetUiState(uiState, { searchParameters }) {
        // The global UI state is merged with a new one to store the UI
        // state of the current widget.
        return {
          ...uiState,
          negativeRefinementList: {
            ...uiState.negativeRefinementList,
            // You can use multiple `negativeRefinementList` widgets in a single
            // app so you need to register each of them separately.
            // Each `negativeRefinementList` widget's UI state is stored by
            // the `attribute` it impacts.
            [attribute]: searchParameters.getExcludeRefinements(attribute),
          },
        };
      },
    };
  };
}

// …

When you initialize its state from a URL, InstantSearch needs to know how to convert it into search parameters so it can trigger its first search.

You can specify how to derive search parameters from the current UI state by implementing the getWidgetSearchParameters method. It gives you access to the current search parameters that you can modify using the widget parameters and current UI state, then return.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export function connectNegativeRefinementList(renderFn, unmountFn = noop) {
  return function negativeRefinementList(widgetParams) {
    const { attribute } = widgetParams;

    return {
      // …
      getWidgetSearchParameters(searchParameters, { uiState }) {
        const state = searchParameters.addFacet(attribute);
        const values = uiState.negativeRefinementList?.[attribute];

        if (Array.isArray(values)) {
          return values.reduce(
            (acc, curr) => acc.addExcludeRefinement(attribute, curr),
            state
          );
        }

        return state;
      },
    };
  };
}

// …

Sending events to the Insights API

You might want to capture when users exclude refinements using the widget to better understand them and their behavior. The Insights API lets you collect such events from the front end to later on unlock key features such as Algolia Recommend, Click and conversion analytics, and many more.

You can set up your widget so it automatically sends the right events to Algolia Insights when using the Insights middleware.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
export function connectNegativeRefinementList(renderFn, unmountFn = noop) {
  return function negativeRefinementList(widgetParams) {
    // …

    return {
      // …
      getWidgetRenderState({ results, helper, instantSearchInstance }) {
        // To ensure `sendEvent` keeps the same reference across renders, create
        // and store it once outside the method scope.
        if (!connectorState.sendEvent) {
          connectorState.sendEvent = (
            eventType,
            facetValue,
            eventName = 'Negative Filter Applied'
          ) => {
            if (helper.state.isExcludeRefined(attribute, facetValue)) {
              instantSearchInstance.sendEventToInsights({
                insightsMethod: 'clickedFilters',
                widgetType: this.$$type,
                eventType,
                payload: {
                  eventName,
                  index: helper.getIndex(),
                  filters: [`${attribute}:-${facetValue}`],
                },
                attribute,
              });
            }
          };
        }

        if (!connectorState.refine) {
          connectorState.refine = (value) => {
            helper.toggleFacetExclusion(attribute, value);
            // Send `click` event once the facet is toggled.
            connectorState.sendEvent!('click', value);

            return helper.search();
          };
        }

        // …
      },
    };
  };
}

// …

Now when clicking on a refinement, it automatically sends an event to Algolia Insights. Note that this can only work when providing an Insights client with the Insights middleware.

You can make the connector even more flexible by providing the sendEvent function to the render function. This lets you customize events depending on the use case.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function connectNegativeRefinementList(renderFn, unmountFn = noop) {
  return function negativeRefinementList(widgetParams) {
    // …

    return {
      // …
      getWidgetRenderState({ results, helper, instantSearchInstance }) {
        // …

        return {
          // …
          sendEvent: connectorState.sendEvent,
        };
      },
    };
  };
}

// …

Using a custom connector as a Hook

To make a connector more idiomatically consumable in a React context, you need to turn it into a Hook.

React InstantSearch Hooks exposes useConnector() to use InstantSearch.js connectors as Hooks.

1
2
3
4
5
6
7
8
9
10
import { useConnector } from 'react-instantsearch-hooks-web';
import { connectNegativeRefinementList } from './connectNegativeRefinementList';

export function useNegativeRefinementList(props, additionalWidgetProperties) {
  return useConnector(
    connectNegativeRefinementList,
    props,
    additionalWidgetProperties
  );
}

You can use the Hook in any React component nested under <InstantSearch> to consume the state from your negative refinement and interact with it.

1
2
3
4
5
6
7
function NegativeCategoriesList() {
  const { items, refine } = useNegativeRefinementList({
    attribute: 'brand',
  });

  return <>{/* Your JSX */}</>;
}

Rendering a custom user interface

An InstantSearch “widget” is a custom connector with a render function. In a React context, it translates to a component that consumes a Hook and renders a UI.

In this example, the <NegativeRefinementList> component uses the useNegativeRefinementList() Hook to build a reactive, stateful UI. This widget is usable in any React InstantSearch Hooks app.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import React from 'react';
import { useNegativeRefinementList } from './useNegativeRefinementList';

export function NegativeRefinementList(props) {
  const { items, refine, canRefine } = useNegativeRefinementList(props, {
    // This is helpful for debugging purposes and allows to differentiate
    // between the connector and the widget.
    $$widgetType: 'myOrganization.negativeRefinementList',
  });

  return (
    <div
      className={cx(
        'ais-NegativeRefinementList',
        !canRefine && 'ais-NegativeRefinementList--noRefinement'
      )}
    >
      <ul className="ais-NegativeRefinementList-list">
        {items.map((item) => (
          <li
            key={item.name}
            className={cx(
              'ais-NegativeRefinementList-item',
              item.isExcluded && 'ais-NegativeRefinementList-item--selected'
            )}
          >
            <label className="ais-NegativeRefinementList-label">
              <input
                checked={item.isExcluded}
                type="checkbox"
                className="ais-NegativeRefinementList-checkbox"
                value={item.name}
                onChange={() => refine(item.name)}
              />
              <span className="ais-NegativeRefinementList-labelText">{item.name}</span>
              <span className="ais-NegativeRefinementList-count">{item.count}</span>
            </label>
          </li>
        ))}
      </ul>
    </div>
  );
}

function cx(...classNames) {
  return classNames.filter(Boolean).join(' ');
}

The widget is now usable in an InstantSearch application. Still, if you want to reuse or distribute the widget, you can further tweak the API to use the same standards as the built-in React InstantSearch Hooks widgets.

Making the widget reusable

You might want to reuse your widget within your app, share it across multiple projects without rewriting everything, or even publish it on npm for others to enjoy.

To do so, you can open APIs to allow customization of the relevant parts of the widgets while abstracting the complexity away.

Forwarding root props

A good first step is to let users forward props to the root element. This is useful for basic styling, accessibility, and testing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import React from 'react';
import { useNegativeRefinementList } from './useNegativeRefinementList';

export function NegativeRefinementList({ attribute, ...props }) {
  const { items, refine, canRefine } = useNegativeRefinementList(
    { attribute },
    {
      $$widgetType: 'myOrganization.negativeRefinementList',
    }
  );

  return (
    <div
      {...props}
      className={cx(
        'ais-NegativeRefinementList',
        !canRefine && 'ais-NegativeRefinementList--noRefinement',
        props.className
      )}
    >
      <ul className="ais-NegativeRefinementList-list">
        {items.map((item) => (
          <li
            key={item.name}
            className={cx(
              'ais-NegativeRefinementList-item',
              item.isExcluded && 'ais-NegativeRefinementList-item--selected'
            )}
          >
            <label className="ais-NegativeRefinementList-label">
              <input
                checked={item.isExcluded}
                type="checkbox"
                className="ais-NegativeRefinementList-checkbox"
                value={item.name}
                onChange={() => refine(item.name)}
              />
              <span className="ais-NegativeRefinementList-labelText">{item.name}</span>
              <span className="ais-NegativeRefinementList-count">{item.count}</span>
            </label>
          </li>
        ))}
      </ul>
    </div>
  );
}

// …

Passing custom classes

The widget already exposes class names to let users write custom CSS, but you could even open the styling API further to allow passing classes directly on each element. This lets users of class-based CSS frameworks like Bootstrap or Tailwind CSS leverage them without friction or workarounds.

In React InstantSearch Hooks default widgets, the convention is to provide a classNames prop which takes an object of named classes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import React from 'react';
import { useNegativeRefinementList } from './useNegativeRefinementList';

export function NegativeRefinementList({
  attribute,
  classNames = {},
  ...props
}) {
  const { items, refine, canRefine } = useNegativeRefinementList(
    { attribute },
    {
      $$widgetType: 'myOrganization.negativeRefinementList',
    }
  );

  return (
    <div
      {...props}
      className={cx(
        'ais-NegativeRefinementList',
        classNames.root,
        !canRefine &&
          cx('ais-NegativeRefinementList--noRefinement', classNames.noRefinementRoot),
        props.className
      )}
    >
      <ul className={cx('ais-NegativeRefinementList-list', classNames.list)}>
        {items.map((item) => (
          <li
            key={item.name}
            className={cx(
              'ais-NegativeRefinementList-item',
              classNames.item,
              item.isExcluded &&
                cx('ais-NegativeRefinementList-item--selected', classNames.selectedItem)
            )}
          >
            <label className={cx('ais-NegativeRefinementList-label', classNames.label)}>
              <input
                checked={item.isExcluded}
                type="checkbox"
                className={cx(
                  'ais-NegativeRefinementList-checkbox',
                  classNames.checkbox
                )}
                value={item.name}
                onChange={() => refine(item.name)}
              />
              <span
                className={cx(
                  'ais-NegativeRefinementList-labelText',
                  classNames.labelText
                )}
              >
                {item.name}
              </span>
              <span
                className={cx('ais-NegativeRefinementList-count', classNames.count)}
              >
                {item.count}
              </span>
            </label>
          </li>
        ))}
      </ul>
    </div>
  );
}

// …

Using the custom widget

You can use a custom widget like any React InstantSearch Hooks widget. It takes the parameters to forward to the Hook, HTML props for the root element, and a classNames object.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import algoliasearch from 'algoliasearch/lite';
import { InstantSearch } from 'react-instantsearch-hooks-web';

import { NegativeRefinementList } from './NegativeRefinementList';

const searchClient = algoliasearch('YourApplicationID', 'YourSearchOnlyAPIKey');

function App() {
  return (
    <InstantSearch searchClient={searchClient} indexName="instant_search">
      {/* … */}
      <NegativeRefinementList
        // Hook parameters
        attribute="brand"
        // HTML root props
        title="My custom title"
        // Custom class names
        classNames={{
          root: 'MyCustomNegativeRefinementList',
          item: 'MyCustomNegativeRefinementListItem',
        }}
      />
    </InstantSearch>
  );
}

This is what the <NegativeRefinementList> widget looks like in a React InstantSearch Hooks app.

Negative refinement list custom widget

Negative refinement list custom widget

Next steps

You now have a good starting point to take full control of your React InstantSearch Hooks experience. Next up, you could go further by:

Did you find this page helpful?