Injecting Content Between Hits with React InstantSearch
This version of React InstantSearch has been deprecated in favor of the latest version of 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.
Build a custom widget
React InstantSearch exposes a connector API that lets you reuse existing logic and plug your own. Use 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 app, 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 withhits
andinfinite-hits
.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 tohitComponent
, 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.
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>
)
}