Guides / Building Search UI / UI & UX patterns

Injecting Content Between Hits with React InstantSearch

We released React InstantSearch Hooks, a new InstantSearch library for React. We recommend using React InstantSearch Hooks in new projects or upgrading from React InstantSearch.

Content injection consists of inserting data between search results. This pattern can be helpful in a variety of use cases:

  • Displaying promotional or editorial content based on the search parameters, like the query or active refinements
  • Inserting promoted brand banners between hits
  • Showing customized suggestions based on the user profile

In such scenarios, you need to be in control of what data is injected, at which position or frequency it’s injected, and how it’s displayed. React InstantSearch lets you inject content coming from another Algolia index, Algolia Rules, or even third-party sources using a custom connector.

This guides teaches you how to build a custom connectInjectedHits connector and plug it to existing React InstantSearch connectors to create flexible search results with injected content. You can position and size injected content statically or dynamically.

Screenshot showing editorial content injected between regular hits

Build a custom widget

React InstantSearch exposes a connector API that lets you reuse existing logic and plug your own. You can leverage it to build a custom React InstantSearch widget to inject content between hits or infinite hits.

The following design is experimental. Make sure to test it in your application, and feel free to change the code to better suit your needs.

Build a custom connector

To inject content between hits, you need to create a custom connector. This connector takes care of mixing regular hits with injected ones.

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
71
72
73
74
75
76
77
78
79
// connectInjectedHits.js
import { createConnector } from 'react-instantsearch-core';

export const connectInjectedHits = createConnector({
  displayName: 'InjectedHits',
  getProvidedProps(props, _, searchResults) {
    const { slots, hits, hitComponent, contextValue } = props;

    const { mainTargetedIndex } = contextValue;
    const results = searchResults.results || [];
    const isSingleIndex = Array.isArray(results.hits);

    // Group results by index name for easier access
    const resultsByIndex = isSingleIndex
      ? { [mainTargetedIndex]: { ...results, hits } }
      : Object.entries(results).reduce((acc, [indexName, indexResults]) => {
          const isMainIndex = indexName === mainTargetedIndex;

          return {
            ...acc,
            [indexName]: isMainIndex ? { ...indexResults, hits } : indexResults,
          };
        }, {});

    const mainIndexHits = resultsByIndex[mainTargetedIndex]?.hits || [];

    // Loop through main hits and inject slots
    const injectedHits = mainIndexHits
      .map((hit, position) => {
        // Wrap main hits and injected hits into a common format
        // for easier templating
        const hitFromMainIndex = {
          type: 'item',
          props: { hit },
          Hit: hitComponent,
        };

        return slots({ resultsByIndex })
          .reverse()
          .reduce(
            (acc, { injectAt, getHits = () => [null], slotComponent }) => {
              const slotScopeProps = { position, resultsByIndex };
              const shouldInject =
                typeof injectAt === 'function'
                  ? injectAt({
                      ...slotScopeProps,
                      hit,
                    })
                  : position === injectAt;

              if (!shouldInject) {
                return acc;
              }

              const hitsFromSlotIndex = getHits({ ...slotScopeProps, hit });

              // Merge injected and main hits
              return [
                ...hitsFromSlotIndex.map((hitFromSlotIndex) => ({
                  type: 'injected',
                  props: {
                    ...slotScopeProps,
                    hit: hitFromSlotIndex,
                  },
                  Hit: slotComponent,
                })),
                ...acc,
              ];
            },
            [hitFromMainIndex]
          );
      })
      .flat();

    return {
      injectedHits,
    };
  },
});

You can use this connector to build a custom widget either with paginated hits or infinite hits.

Connect a render function

To build an injected hits widget, you can nest connectInjectedHits within the connectHits or connectInfiniteHits connector.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// InjectedHits.js
import React from 'react';
import { createClassNames, connectHits } from 'react-instantsearch-dom';

import { connectInjectedHits } from './connectInjectedHits';

const cx = createClassNames('Hits');

export const InjectedHits = connectHits(
  connectInjectedHits(({ injectedHits }) => (
    <div className={cx('')}>
      <ul className={cx('list')}>
        {injectedHits.map(({ props, type, Hit }, index) => {
          return (
            <li key={index} className={cx(type)}>
              <Hit {...props} />
            </li>
          );
        })}
      </ul>
    </div>
  ))
);

You can now use the custom widget in your React InstantSearch implementation.

API and usage

The custom widget takes two props:

  • hitComponent: a component to render each regular hit. It’s the same prop as with Hits and InfiniteHits.
  • slots: a function that returns an array of slots to inject.

A slot represents blocks to insert between regular Algolia hits. They don’t necessarily translate into a single item: you can insert multiple elements in a single slot. They also don’t necessarily translate into a single position: a slot can be inserted at different positions. However, a slot is a single definition of a content insertion behavior.

Each slot takes:

  • injectAt: a static position (as a number), or a predicate which returns a boolean, to determine where to inject each slot.
  • slotComponent: a component to render each injected hit. It’s similar to hitComponent, but for injected hits.
  • getHits: a function which returns the hits to inject. This is only useful when the data to inject comes from an Algolia index or an Algolia Rule.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type ResultsByIndex = Record<string, SearchResults>;

type SlotScopeProps<Hit> = {
  position: number;
  resultsByIndex: ResultsByIndex;
  hit: THit | null
};

type Slot = {
  injectAt: number | (props: SlotScopeProps) => boolean;
  slotComponent: <THit>(props: SlotScopeProps) => React.ReactNode;
  getHits?: <THit>(props: SlotScopeProps) => THit[];
};

type InjectedHitsProps = {
  slots: (props: { resultsByIndex: ResultsByIndex }) => Slot[];
  hitComponent: (props: HitComponentProps) => React.ReactNode;
};

Usage looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<InjectedHits
  slots={() => [
    {
      // Injects the slot every 5th item
      injectAt: ({ position }) => position % 5 === 0,
      // Returns the hits to inject from Algolia results
      getHits: ({ resultsByIndex }) => resultsByIndex.recipes?.hits || [],
      // A React component dedicated to this slot
      slotComponent: RecipeHit,
    },
  ]}
  // A React component for the regular hits
  hitComponent={IngredientHit}
/>

Inject custom content between hits

Once you have a working custom widget, you can use it to display regular Algolia hits and inject content between. Injected hits can either come from Algolia (another Algolia index, an Algolia Rule), or not (a static file, a third-party API).

Inject non-adjacent hits

You can pass as many slots as you want to the custom widget. However, sometimes you might want to intersperse injected items instead of defining a fixed slot position. For example, you might want to inject a recipe every five ingredients.

You can do this using a single slot by passing a predicate to injectAt instead of a static position.

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
import React from 'react';
import {
  InstantSearch,
  Configure,
  Index,
  SearchBox,
} from 'react-instantsearch-dom';

import { InjectedHits } from './InjectedHits';

function App() {
  return (
    <InstantSearch searchClient={searchClient} indexName="ingredients">
      <Configure hitsPerPage={8} />
      <Index indexName="recipes">
        <Configure hitsPerPage={100} page={0} />
      </Index>
      <SearchBox />
      <InjectedHits
        slots={() => [
          {
            injectAt: ({ position }) => position % 5 === 0,
            getHits: ({ position, resultsByIndex }) => {
              const index = position / 5;
              const item = resultsByIndex.recipes?.hits[index];

              return item ? [item] : [];
            },
            slotComponent: RecipeHit,
          },
        ]}
        hitComponent={IngredientHit}
      />
    </InstantSearch>
  );
}

// ...

Now, the first recipe would show up in position 0, then the second recipe after five ingredients, etc.

Screenshot showing recipes injected between regular hits with a specific frequency

Dynamically size hits

While you might already want to store slot positions in your Algolia hits or Rules, you can store other pieces of presentational data. For example, you might want to display hits as a grid but for your injected content to span multiple rows or columns.

One way of achieving this is to use the CSS Grid Layout to display all your hits. Then, you can specify sizing information at the content level and use that in your code.

1
2
3
4
5
6
7
8
9
10
11
12
13
[
  {
    "position": 3,
    "size": {
      "columns": 2,
      "rows": 1
    },
    "title": "Butter chicken",
    "ingredients": [
      // ...
    ]
  }
]

The CSS Grid Layout requires for sized elements to be direct descendants of the grid container. To do so, you can adjust the custom widget’s render function and remove the wrapping element around the slotComponent.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React, { Fragment } from 'react';
import { connectHits } from 'react-instantsearch-dom';

import { connectInjectedHits } from './connectInjectedHits';

const InjectedHits = connectHits(
  connectInjectedHits(({ injectedHits }) => (
    <div style={{
      display: 'grid',
      gridTemplateColumns: 'repeat(4, 1fr)',
      gridAutoFlow: 'dense',
      gap: '1rem',
    }}>
      {injectedHits.map(({ props, type, Hit }, index) => {
        return (
          <Fragment key={index}>
            <Hit {...props} />
          </Fragment>
        );
      })}
    </div>
  ))
);

Then, you can use the retrieved size directly in your slotComponent to specify how they should span in the grid.

1
2
3
4
5
6
7
8
9
10
11
// ...

function BannerHit({ hit }) {
  const { columns = 1, rows = 1 } = hit.size;

  return (
    <div style={{ gridColumn: `span ${columns}`, gridRow: `span ${rows}` }}>
      {/* ... */}
    </div>
  )
}
Did you find this page helpful?