Skip to main content
Connectors separate business logic from rendering, enabling you to build reusable search components that work with any UI framework or rendering library.

Overview

A connector is a function that takes a rendering function and returns a widget factory. This pattern lets you:
  • Reuse logic across different UI implementations
  • Test business logic independently from rendering
  • Support multiple frameworks with the same core logic
  • Build custom widgets with clean separation of concerns

Basic Connector Structure

function connectMyWidget(renderFn, unmountFn = () => {}) {
  return (widgetParams = {}) => {
    // Widget implementation
    return {
      $$type: 'ais.myWidget',
      
      init(initOptions) {
        renderFn(
          {
            // Render state
            ...this.getWidgetRenderState(initOptions),
            instantSearchInstance: initOptions.instantSearchInstance,
          },
          true // isFirstRender
        );
      },
      
      render(renderOptions) {
        renderFn(
          {
            ...this.getWidgetRenderState(renderOptions),
            instantSearchInstance: renderOptions.instantSearchInstance,
          },
          false // isFirstRender
        );
      },
      
      getWidgetRenderState({ results, state, helper }) {
        // Return data for rendering
        return {
          items: results?.hits || [],
          widgetParams,
        };
      },
      
      dispose() {
        unmountFn();
      },
    };
  };
}

Simple Connector Example

Let’s create a connector for a hit counter:
function connectHitCount(renderFn, unmountFn = () => {}) {
  return (widgetParams = {}) => {
    return {
      $$type: 'custom.hitCount',
      
      init(initOptions) {
        renderFn(
          {
            nbHits: 0,
            widgetParams,
            instantSearchInstance: initOptions.instantSearchInstance,
          },
          true
        );
      },
      
      render(renderOptions) {
        const { results } = renderOptions;
        
        renderFn(
          {
            nbHits: results.nbHits,
            widgetParams,
            instantSearchInstance: renderOptions.instantSearchInstance,
          },
          false
        );
      },
      
      dispose() {
        unmountFn();
      },
    };
  };
}

// Usage with vanilla JS
const renderHitCount = ({ nbHits, widgetParams }, isFirstRender) => {
  const { container } = widgetParams;
  
  if (isFirstRender) {
    const div = document.createElement('div');
    div.id = 'hit-count';
    document.querySelector(container).appendChild(div);
  }
  
  document.querySelector('#hit-count').innerHTML = `
    <p><strong>${nbHits.toLocaleString()}</strong> results</p>
  `;
};

const hitCountWidget = connectHitCount(renderHitCount);

search.addWidgets([
  hitCountWidget({ container: '#stats' }),
]);

Connector with Interactions

Create a connector that supports user interactions:
function connectSearchBox(renderFn, unmountFn = () => {}) {
  return (widgetParams = {}) => {
    let helper;
    let query = '';
    
    return {
      $$type: 'custom.searchBox',
      
      init(initOptions) {
        helper = initOptions.helper;
        query = helper.state.query || '';
        
        renderFn(
          {
            query,
            refine: (newQuery) => {
              query = newQuery;
              helper.setQuery(query).search();
            },
            clear: () => {
              query = '';
              helper.setQuery('').search();
            },
            widgetParams,
            instantSearchInstance: initOptions.instantSearchInstance,
          },
          true
        );
      },
      
      render(renderOptions) {
        query = renderOptions.state.query || '';
        
        renderFn(
          {
            query,
            refine: (newQuery) => {
              query = newQuery;
              helper.setQuery(query).search();
            },
            clear: () => {
              query = '';
              helper.setQuery('').search();
            },
            widgetParams,
            instantSearchInstance: renderOptions.instantSearchInstance,
          },
          false
        );
      },
      
      getWidgetUiState(uiState, { searchParameters }) {
        const query = searchParameters.query || '';
        
        if (!query) {
          return uiState;
        }
        
        return {
          ...uiState,
          query,
        };
      },
      
      getWidgetSearchParameters(searchParameters, { uiState }) {
        return searchParameters.setQuery(uiState.query || '');
      },
      
      dispose() {
        unmountFn();
      },
    };
  };
}

// Render function
const renderSearchBox = (renderOptions, isFirstRender) => {
  const { query, refine, clear, widgetParams } = renderOptions;
  const { container } = widgetParams;
  
  if (isFirstRender) {
    const input = document.createElement('input');
    input.id = 'search-input';
    input.type = 'text';
    input.placeholder = 'Search...';
    
    const button = document.createElement('button');
    button.id = 'clear-button';
    button.textContent = 'Clear';
    
    const containerNode = document.querySelector(container);
    containerNode.appendChild(input);
    containerNode.appendChild(button);
    
    input.addEventListener('input', (event) => {
      refine(event.target.value);
    });
    
    button.addEventListener('click', () => {
      clear();
      input.value = '';
    });
  }
  
  document.querySelector('#search-input').value = query;
};

const searchBoxWidget = connectSearchBox(renderSearchBox);

search.addWidgets([
  searchBoxWidget({ container: '#searchbox' }),
]);

Advanced Connector: Facet Refinement

Build a full-featured facet connector:
import { checkRendering, createDocumentationMessageGenerator } from 'instantsearch.js/es/lib/utils';

const withUsage = createDocumentationMessageGenerator({
  name: 'custom-facet',
  connector: true,
});

function connectCustomFacet(renderFn, unmountFn = () => {}) {
  checkRendering(renderFn, withUsage());
  
  return (widgetParams = {}) => {
    const {
      attribute,
      limit = 10,
      sortBy = ['isRefined', 'count:desc', 'name:asc'],
    } = widgetParams;
    
    if (!attribute) {
      throw new Error(withUsage('The `attribute` option is required.'));
    }
    
    return {
      $$type: 'custom.facet',
      
      init(initOptions) {
        renderFn(
          {
            ...this.getWidgetRenderState(initOptions),
            instantSearchInstance: initOptions.instantSearchInstance,
          },
          true
        );
      },
      
      render(renderOptions) {
        renderFn(
          {
            ...this.getWidgetRenderState(renderOptions),
            instantSearchInstance: renderOptions.instantSearchInstance,
          },
          false
        );
      },
      
      getWidgetRenderState({ results, helper, state }) {
        if (!results) {
          return {
            items: [],
            refine: () => {},
            createURL: () => '#',
            widgetParams,
          };
        }
        
        const facetValues = results.getFacetValues(attribute, { sortBy }) || [];
        const items = facetValues.slice(0, limit);
        
        return {
          items,
          refine: (value) => {
            helper.toggleFacetRefinement(attribute, value).search();
          },
          createURL: (value) => {
            const nextState = helper.state.toggleFacetRefinement(attribute, value);
            return helper.instantSearchInstance._createURL({ [helper.state.index]: nextState });
          },
          canToggleShowMore: facetValues.length > limit,
          isShowingMore: false,
          toggleShowMore: () => {},
          widgetParams,
        };
      },
      
      getWidgetSearchParameters(searchParameters) {
        return searchParameters.addDisjunctiveFacet(attribute);
      },
      
      getWidgetUiState(uiState, { searchParameters }) {
        const values = searchParameters.getDisjunctiveRefinements(attribute);
        
        if (!values.length) {
          return uiState;
        }
        
        return {
          ...uiState,
          refinementList: {
            ...uiState.refinementList,
            [attribute]: values,
          },
        };
      },
      
      dispose({ state }) {
        unmountFn();
        return state.removeDisjunctiveFacet(attribute);
      },
    };
  };
}

Real-World Example: Stats Connector

Here’s how the actual connectStats is implemented in InstantSearch:
// Simplified from src/connectors/stats/connectStats.ts
function connectStats(renderFn, unmountFn = noop) {
  checkRendering(renderFn, withUsage());
  
  return (widgetParams) => {
    return {
      $$type: 'ais.stats',
      
      init(initOptions) {
        renderFn(
          {
            ...this.getWidgetRenderState(initOptions),
            instantSearchInstance: initOptions.instantSearchInstance,
          },
          true
        );
      },
      
      render(renderOptions) {
        renderFn(
          {
            ...this.getWidgetRenderState(renderOptions),
            instantSearchInstance: renderOptions.instantSearchInstance,
          },
          false
        );
      },
      
      getRenderState(renderState, renderOptions) {
        return {
          ...renderState,
          stats: this.getWidgetRenderState(renderOptions),
        };
      },
      
      getWidgetRenderState({ results, state }) {
        if (!results) {
          return {
            hitsPerPage: state.hitsPerPage,
            nbHits: 0,
            nbSortedHits: undefined,
            areHitsSorted: false,
            nbPages: 0,
            page: state.page || 0,
            processingTimeMS: -1,
            query: state.query || '',
            widgetParams,
          };
        }
        
        return {
          hitsPerPage: results.hitsPerPage,
          nbHits: results.nbHits,
          nbSortedHits: results.nbSortedHits,
          areHitsSorted: results.appliedRelevancyStrictness !== undefined &&
            results.appliedRelevancyStrictness > 0 &&
            results.nbSortedHits !== results.nbHits,
          nbPages: results.nbPages,
          page: results.page,
          processingTimeMS: results.processingTimeMS,
          query: results.query,
          widgetParams,
        };
      },
      
      dispose() {
        unmountFn();
      },
    };
  };
}

Using with React

Connectors work seamlessly with React:
import { connectSearchBox } from 'instantsearch.js/es/connectors';
import { useConnector } from 'react-instantsearch';

function CustomSearchBox(props) {
  const { query, refine, clear } = useConnector(connectSearchBox, props);
  
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => refine(e.target.value)}
        placeholder="Search..."
      />
      <button onClick={clear}>Clear</button>
    </div>
  );
}

Using with Vue

<template>
  <div>
    <input
      :value="state.query"
      @input="state.refine($event.target.value)"
      placeholder="Search..."
    />
    <button @click="state.clear()">Clear</button>
  </div>
</template>

<script>
import { connectSearchBox } from 'instantsearch.js/es/connectors';

export default {
  data() {
    return {
      state: {},
    };
  },
  created() {
    const renderSearchBox = (renderOptions) => {
      this.state = renderOptions;
    };
    
    const makeWidget = connectSearchBox(renderSearchBox);
    this.widget = makeWidget({});
    
    this.$root.instantsearch.addWidgets([this.widget]);
  },
  beforeDestroy() {
    this.$root.instantsearch.removeWidgets([this.widget]);
  },
};
</script>

Connector Best Practices

Validate Parameters

Check required parameters and provide helpful error messages.

Handle First Render

Use isFirstRender to set up event listeners only once.

Provide Render State

Return all necessary data from getWidgetRenderState.

Clean Up

Always call unmountFn in the dispose method.

Helper Utilities

Use InstantSearch utilities in your connectors:
import {
  checkRendering,
  createDocumentationMessageGenerator,
  noop,
  warning,
} from 'instantsearch.js/es/lib/utils';

const withUsage = createDocumentationMessageGenerator({
  name: 'my-connector',
  connector: true,
});

function connectMyWidget(renderFn, unmountFn = noop) {
  // Validate render function
  checkRendering(renderFn, withUsage());
  
  return (widgetParams = {}) => {
    const { attribute } = widgetParams;
    
    // Validate parameters
    if (!attribute) {
      throw new Error(withUsage('The `attribute` option is required.'));
    }
    
    // Warn about deprecated options
    warning(
      !widgetParams.deprecated,
      withUsage('The `deprecated` option is no longer supported.')
    );
    
    return {
      // Widget implementation
    };
  };
}

Testing Connectors

Connectors are easy to test because they separate logic from rendering:
import { connectCustomFacet } from './connectors';

describe('connectCustomFacet', () => {
  it('provides items to render function', () => {
    const renderFn = jest.fn();
    const makeWidget = connectCustomFacet(renderFn);
    const widget = makeWidget({ attribute: 'brand' });
    
    widget.render({
      results: {
        getFacetValues: () => [
          { name: 'Apple', count: 100, isRefined: false },
          { name: 'Samsung', count: 80, isRefined: false },
        ],
      },
      state: {},
      helper: {
        toggleFacetRefinement: jest.fn(),
      },
    });
    
    expect(renderFn).toHaveBeenCalledWith(
      expect.objectContaining({
        items: expect.arrayContaining([
          expect.objectContaining({ name: 'Apple' }),
        ]),
      }),
      false
    );
  });
});

Complete Example

import { checkRendering, createDocumentationMessageGenerator, noop } from 'instantsearch.js/es/lib/utils';

const withUsage = createDocumentationMessageGenerator({
  name: 'toggle',
  connector: true,
});

function connectToggle(renderFn, unmountFn = noop) {
  checkRendering(renderFn, withUsage());
  
  return (widgetParams = {}) => {
    const { attribute, on = true, off } = widgetParams;
    
    if (!attribute) {
      throw new Error(withUsage('The `attribute` option is required.'));
    }
    
    return {
      $$type: 'custom.toggle',
      
      init(initOptions) {
        renderFn(
          {
            ...this.getWidgetRenderState(initOptions),
            instantSearchInstance: initOptions.instantSearchInstance,
          },
          true
        );
      },
      
      render(renderOptions) {
        renderFn(
          {
            ...this.getWidgetRenderState(renderOptions),
            instantSearchInstance: renderOptions.instantSearchInstance,
          },
          false
        );
      },
      
      getWidgetRenderState({ helper, results, state }) {
        const isRefined = state.isDisjunctiveFacetRefined(attribute, on);
        const onFacetValue = results?.getFacetByName?.(attribute)?.data?.[on];
        const offFacetValue = off !== undefined
          ? results?.getFacetByName?.(attribute)?.data?.[off]
          : undefined;
        
        return {
          value: {
            name: attribute,
            isRefined,
            count: results
              ? isRefined
                ? offFacetValue?.count || null
                : onFacetValue?.count || 0
              : null,
            onFacetValue,
            offFacetValue,
          },
          refine: () => {
            helper.toggleFacetRefinement(attribute, on).search();
          },
          createURL: () => {
            const nextState = state.toggleFacetRefinement(attribute, on);
            return helper.instantSearchInstance._createURL({
              [state.index]: nextState,
            });
          },
          widgetParams,
        };
      },
      
      getWidgetSearchParameters(searchParameters) {
        return searchParameters.addDisjunctiveFacet(attribute);
      },
      
      getWidgetUiState(uiState, { searchParameters }) {
        const isRefined = searchParameters.isDisjunctiveFacetRefined(attribute, on);
        
        if (!isRefined) {
          return uiState;
        }
        
        return {
          ...uiState,
          toggle: {
            ...uiState.toggle,
            [attribute]: isRefined,
          },
        };
      },
      
      dispose({ state }) {
        unmountFn();
        return state.removeDisjunctiveFacet(attribute);
      },
    };
  };
}

export default connectToggle;

Build docs developers (and LLMs) love