Customize an InstantSearch.js widget
On this page
Highlight and snippet your search results
Search is all about helping users understand the results. This is especially true when using text based search. When a user types a query in the search box, the results must show why the results are matching the query. That’s why Algolia implements a powerful highlighting that lets you display the matching parts of text attributes in the results. On top of that, Algolia implements snippeting to get only the meaningful part of a text, when attributes have a lot of content.
This feature is already packaged for you in InstantSearch.js through two functions highlight
and snippet
.
Usage with templates
You have a direct access to the highlight and snippet functions through the template system.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const search = instantsearch({
indexName: 'instant_search'
searchClient,
});
search.addWidgets([
instantsearch.widgets.hits({
container: '#hits',
templates: {
item: `
<article>
<p>Name: {{#helpers.highlight}}{ "attribute": "name", "highlightedTagName": "mark" }{{/helpers.highlight}}</p>
<p>Description: {{#helpers.snippet}}{ "attribute": "description", "highlightedTagName": "mark" }{{/helpers.snippet}}</p>
</article>
`
}
})
]);
Usage with render functions
You can also use the highlight and snippet functions through render functions.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const search = instantsearch({
indexName: 'instant_search'
searchClient,
});
search.addWidgets([
instantsearch.widgets.hits({
container: '#hits',
templates: {
item(hit) {
return `
<article>
<p>Name: ${instantsearch.highlight({ attribute: 'name', highlightedTagName: 'mark', hit })}</p>
<p>Name: ${instantsearch.snippet({ attribute: 'name', highlightedTagName: 'mark', hit })}</p>
</article>
`;
}
}
})
]);
Style your widgets
All widgets in InstantSearch.js namespace are shipped with CSS class names that can be overriden.
The format for those class names is ais-NameOfWidget-element--modifier
(following the naming convention defined by SUIT CSS).
The different class names used by each widget are described on their respective documentation pages. You can also inspect the underlying DOM and style accordingly.
Loading the theme
No CSS is automatically loaded into your page but there are two themes that you can load manually:
- reset.css
- satellite.css
It’s strongly recommended that you use at least reset.css to avoid visual side effects caused by the new HTML semantics.
The reset
theme CSS is included within the satellite
CSS, so there is no need to import it separately when you are using the satellite
theme.
Via CDN
The themes are available on jsDelivr:
-
unminified:
-
minified:
You can either copy paste the content into your own app or use a direct link to jsDelivr:
1
2
3
4
5
<!-- Include only the reset -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/instantsearch.css@8.1.0/themes/reset-min.css" integrity="sha256-2AeJLzExpZvqLUxMfcs+4DWcMwNfpnjUeAAvEtPr0wU=" crossorigin="anonymous">
<!-- or include the full Satellite theme -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/instantsearch.css@8.1.0/themes/satellite-min.css" integrity="sha256-p/rGN4RGy6EDumyxF9t7LKxWGg6/MZfGhJM/asKkqvA=" crossorigin="anonymous">
Using npm and webpack
1
2
npm install instantsearch.css
npm install --save-dev style-loader css-loader
1
2
3
4
// Include only the reset
import 'instantsearch.css/themes/reset.css';
// or include the full Satellite theme
import 'instantsearch.css/themes/satellite.css';
1
2
3
4
5
6
7
8
9
10
module.exports = {
module: {
rules: [
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
};
Other bundlers
Any other module bundler like Browserify or Parcel can be used to load the Algolia CSS. InstantSearch.js doesn’t rely on any specific module bundler or module loader.
CSS class override
You can override the class names of every widgets with the cssClasses
option. The different key provided by each widget to override the class are described on their respective documentation pages. Here is an example with the hits
widget:
1
2
3
4
5
6
7
8
search.addWidgets([
instantsearch.widgets.hits({
container: '#hits',
cssClasses: {
item: 'item-custom-css-class',
},
})
]);
Styling icons
You can style the icon colors using the widget class names:
1
2
3
4
.ais-SearchBox-submitIcon path
.ais-SearchBox-resetIcon path {
fill: red,
}
Translate your widgets
Most elements in InstantSearch.js widgets can be customized by means of templates. Those templates can be text labels or complete piece of HTML. Underneath, InstantSearch.js is using Hogan, which is an implementation of mustache, the logic-less templating.
Example: Translating the “show more” label
Here is an example of a menu widget with a show more label translated in french:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const search = instantsearch({
// provide the search client here
});
search.addWidgets([
instantsearch.widgets.menu({
attribute: '...',
templates: {
showMoreText: `
{{#isShowingMore}}
Voir moins…
{{/isShowingMore}}
{{^isShowingMore}}
Voir plus…
{{/isShowingMore}}
`,
}
})
]);
Templating your UI
InstantSearch.js widgets provide a Templates API to customize parts of the UI. This lets you customize icons, provide a hit component, define an empty state, change the default labels, etc.
Templates can be either functions or strings. You can provide templates in several ways:
- HTML strings with
html
(with function-based templates). This is the recommended way, especially if you’re using InstantSearch.js without a build step. - JSX templates (with function-based templates). This is useful if you’re already using a JSX implementation (like Preact) in your application.
- Hogan.js templates (with string-based templates). This method is deprecated and shouldn’t be used for new templates.
- “Classic” HTML strings (with string-based templates). This method is deprecated and shouldn’t be used for new templates.
1
2
3
4
5
6
7
8
searchBox({
// ...
templates: {
submit() {
return 'Submit';
},
},
});
You might want to customize a part of the widget that the template system doesn’t cover. For these use cases take a look at the section extending widgets.
Using HTML strings with html
Every InstantSearch.js template provides an html
function that you can use as a tagged template. Using html
lets you safely provide templates as an HTML string. It works directly in the browser, no need for a transpiler or a build step.
The html
function is only available starting from v4.46.0.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
hits({
// ...
templates: {
item(hit, { html, components, sendEvent }) {
return html`
<h2>${components.Highlight({ hit, attribute: 'name' })}</h2>
<p>${components.Snippet({ hit, attribute: 'description' })}</p>
<button
onClick="${() => {
sendEvent('conversion', hit, 'Product Added');
}}"
>
Add to cart
</button>
`;
},
},
});
Internet Explorer 11 doesn’t support tagged template literals. If you need to support Internet Explorer 11, check out the suggested solutions.
Highlighting and snippeting
In both hits
and infiniteHits
widgets, templates expose a set of built-in components to handle highlighting and snippeting. You can use either their function form, or interpolate them.
1
2
3
4
5
6
7
8
9
10
11
hits({
// ...
templates: {
item(hit, { html, components }) {
return html`
<h2>${components.Highlight({ hit, attribute: 'name' })}</h2>
<p>${components.Snippet({ hit, attribute: 'description' })}</p>
`;
},
},
});
Four components are available:
Highlight
to highlight matching parts in Algolia results.Snippet
to snippet matching parts in Algolia results.ReverseHighlight
to highlight non-matching parts in Algolia results.ReverseSnippet
to highlight and snippet non-matching parts in Algolia results.
Loops and conditional rendering
You can use plain JavaScript to build dynamic templates.
For example, you can use Array.map
to loop over an array and display a list.
1
2
3
4
5
6
7
8
9
10
11
12
13
ratingMenu({
// …
templates: {
item({ url, stars }, { html }) {
return html`<a href="${url}">
${stars.map(
(_, index) =>
html`<svg key="${index}"><!-- … --></svg>`
)}
</a>`;
},
},
});
Passing a unique key
attribute is helpful when mapping over items. It helps the virtual DOM keep track of each element when they change, and update the UI efficiently.
To conditionally render a part of your UI, you can use a short-circuit operator or a ternary.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ratingMenu({
// …
templates: {
item({ url, stars }, { html }) {
return html`<a href="${url}">
${stars.map(
(isFilled, index) =>
html`<svg key="${index}" fill="${isFilled ? '#000' : 'none'}">
<!-- … -->
</svg>`
)}
</a>`;
},
},
});
Internet Explorer 11 support
You can’t use html
as a tagged template in Internet Explorer 11. Depending on your project setup, you can work around this problem to still use html
while providing compatible code to your Internet Explorer 11 users.
With Babel
If you’re already using Babel to compile your code for legacy browsers, you can transform all html
expressions into regular function calls.
The recommended setup is to use @babel/preset-env
.
1
2
3
{
"presets": [["@babel/preset-env"]]
}
With a shim
If you don’t have a build step in your project, you can write a shim. Tagged templates are regular functions with a specific signature, so you can wrap their calls with a friendlier API to avoid using tagged template notation.
This function takes templates either as static strings, or as an array of interspersed chunks, splits them, and passes them to the html
function.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function htmlShim(template, html) {
if (typeof template === 'string') {
return html([template]);
}
const parts = template.reduce(
(acc, part, index) => {
const isEven = index % 2 === 0;
acc[Math.abs(Number(!isEven))].push(part);
return acc;
},
[[], []]
);
return html(parts[0], ...parts[1]);
}
The documented shim assumes every even array entry is a template string, and every odd entry is a dynamic value. You can adapt it if you need a different behavior.
You can also get this transform as a standalone Babel plugin.
Further optimizations
The provided html
function works in the browser without any build step, with a negligible impact on memory and bundle size (< 600 bytes).
For optimal performance, you can use the babel-plugin-htm
Babel plugin to compile html
into preact.createElement
calls.
To use this plugin, you need to adapt your code so that the pragma to replace html
calls with is always accessible. For example, instead of destructuring in the signature, you need to name the parameter and destructure it in the function body—or not destructure it at all. The parameter (here, params
) must have the same name in every template.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
hits({
// ...
templates: {
- item(hit, { html, components }) {
+ item(hit, params) {
+ const { html, components } = params;
return html`
<h2>${components.Highlight({ hit, attribute: 'name' })}</h2>
<p>${components.Snippet({ hit, attribute: 'description' })}</p>
`;
},
},
});
Then, you can set up the Babel plugin.
1
2
3
4
5
6
7
8
9
10
{
"plugins": [
[
"htm",
{
"pragma": "params.createElement"
}
]
]
}
If you’re destructuring objects, make sure to also transpile it using @babel/preset-env
.
Using JSX
If you’re already using JSX in your project, you can directly return JSX templates.
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
/** @jsx h */
import { h } from 'preact';
searchBox({
// ...
templates: {
submit({ cssClasses }) {
return (
<svg
className={cssClasses.submitIcon}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
>
<title>Search</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
},
},
});
Using Hogan.js
You can provide templates using Hogan.js, a Mustache implementation. For a comprehensive reference, check the respective documentations.
Hogan.js and string-based templates are deprecated and won’t be supported in InstantSearch.js 5.x. You can replace them with function-form templates and use either the provided html
function or JSX templates.
Check the migration guide for more examples.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
searchBox({
// ...
templates: {
submit: `<svg
class="{{cssClasses.submitIcon}}"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<title>Search</title>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>`,
},
});
Using classic HTML strings
You can provide plain HTML strings using string-based templates. They’re injected as HTML.
Classic HTML strings and string-based templates are deprecated and won’t be supported in InstantSearch.js 5.x. You can replace them with function-form templates and use either the provided html
function or JSX templates.
Check the migration guide for more examples.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
searchBox({
// ...
templates: {
submit: `<svg
class="${cssClasses.submitIcon}"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<title>Search</title>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>`,
},
});
Modify the list of items in widgets
Every widget and connector that handles a list of items exposes a transformItems
option. This option is a function that takes the items as a parameter and expects to return the items back. This option can be used to sort, filter, and add manual values.
Sorting
In this example, the transformItems
option to order the items by label
in a ascending mode:
1
2
3
4
5
6
7
8
9
10
search.addWidgets([
instantsearch.widgets.refinementList({
container: '#brands',
attribute: 'brand',
transformItems(items) {
// Assume that LoDash is available
return _.orderBy(items, 'label', 'asc');
}
})
]);
Some widgets and connectors also provide a sortBy
option. It accepts either an array of strings, like so:
1
2
3
4
5
6
7
search.addWidgets([
instantsearch.widgets.refinementList({
container: '#brands',
attribute: 'brand',
sortBy: ['isRefined', 'count:desc', 'name:asc']
})
]);
Or use a comparison function:
1
2
3
4
5
6
7
8
9
10
11
search.addWidgets([
instantsearch.widgets.refinementList({
container: '#brands',
attribute: 'brand',
sortBy: (a, b) {
// if a should be before b return -1
// if b should be before a return 1
// otherwise return 0
},
})
]);
Filtering
This example uses the transformItems
option to filter out items when the count is lower than 150
1
2
3
4
5
6
7
8
9
search.addWidgets([
instantsearch.widgets.refinementList({
container: '#brands',
attribute: 'brand',
transformItems(items) {
return items.filter(item => item.count >= 150);
}
})
]);
Add manual values
By default, the values in a RefinementList
or a Menu
are dynamic. This means that the values are updated with the context of the search. Most of the time this is the expected behavior, but sometimes you may want to have a static list of values that never change. To achieve this, you can use the connectors.
This example uses the connectRefinementList
connector to display a static list of values. This RefinementList
will always display and only display the items “Apple” and “Microsoft”.
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
const staticRefinementList = instantsearch.connectors.connectRefinementList(
({ items, refine, widgetParams }, isFirstRender) => {
const container = document.getElementById('brand-list');
if (isFirstRender) {
container.addEventListener('click', ({target}) => {
const input = target.closest('input');
if (input) {
refine(input.value);
}
});
return;
}
const list = widgetParams.items.map(({label: staticLabel, value}) => {
const { isRefined } = items.find(
({label}) => label === staticLabel
) || {
isRefined: false,
};
return `
<li>
<label>
<input
type="checkbox"
value="${value}"
${isRefined ? 'checked' : ''}
/>
${staticLabel}
</label>
</li>
`;
});
container.innerHTML = `
<ul>
${list.join('')}
</ul>
`;
}
);
search.addWidgets([
staticRefinementList({
attribute: 'brand',
items: [
{ label: 'Apple', value: 'Apple' },
{ label: 'Microsoft', value: 'Microsoft' },
],
})
]);
Display facets with no matches
Hiding facets when they don’t match a query can be counter-intuitive. However, because of the way Algolia handles faceting, you have to rely on workarounds on the frontend to display facets when they have no hits.
One way of displaying facets with no matches is by caching the results the first time you receive them. Then, if the amount of real facet hits that Algolia returns is below the limit set, you can append the cached facets to the list.
This solution comes with limitations:
- Facet hits coming from a facet search (“Search for facet values”) can’t work because Algolia doesn’t return facets that don’t match (the highlighting won’t work on cached items).
- Sorting might need to be applied again in the
transformItems
function because the internal sorting happens before this function is called
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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
function uniqBy(items, property) {
const seen = {};
return items.filter(item => {
const val = item[property];
if (seen[val]) {
return false;
} else {
seen[val] = true;
return true;
}
});
}
const searchClient = algoliasearch(
'latency',
'6be0576ff61c053d5f9a3225e2a90f76'
);
const indexName = 'instant_search';
const brandAttribute = 'brand';
const brandLimit = 10;
const search = instantsearch({
indexName,
searchClient,
});
const initialFacets = [];
// On the first load, we store all facets returned by Algolia
// so we can append them to the returned facets when Algolia return enough.
// We set their `count` to 0 because they'll show up when nothing
// else matches.
// Note that this triggers another query.
searchClient
.searchForFacetValues([
{
indexName,
params: {
facetName: brandAttribute,
facetQuery: '',
maxFacetHits: brandLimit,
},
},
])
.then(([{ facetHits }]) => {
initialFacets.push(
...facetHits.map(facet => ({
...facet,
label: facet.value,
value: facet.value,
isRefined: false,
count: 0,
}))
);
});
search.addWidgets([
instantsearch.widgets.refinementList({
container: '#brand-list',
attribute: brandAttribute,
limit: brandLimit,
transformItems: items => {
// If Algolia doesn't return enough results, we lose track of a
// potentially refined facet.
// For example, if you refine on "Apple", then search for "chromecast",
// "Apple" is no longer returned, and we don't know that it was selected
// based on the initial facets.
// We need to keep track of the last state to reflect the fact that it
// was refined in the UI.
initialFacets.forEach((facet, index) => {
const updatedItem = items.find(item => item.value === facet.value);
if (updatedItem) {
initialFacets[index] = {
...updatedItem,
count: 0,
};
}
});
// If a cached facet is already returned by Algolia, we want it to be
// displayed rather than to display its cached value.
// You might need to sort the items again here because the internal
// sorting happens before `transformItems` is called.
return uniqBy([...items, ...initialFacets], 'value').slice(0, brandLimit);
},
})
]);
search.start();
Searching long lists
For some cases, you want to be able to directly search into a list of facet values. This can be achieved using the searchable
prop on widgets like refinementList
or connectRefinementList
connector. To enable this feature, you’ll need to make the attribute searchable using the API or the Dashboard).
With widgets
Use the searchable
prop to add a search box to supported widgets:
1
2
3
4
5
6
7
search.addWidgets([
instantsearch.widgets.refinementList({
container: '#brand-list',
attribute: 'brand',
searchable: true,
})
]);
With connectors
You can implement your own search box for searching for items in lists when using supported connectors by using those provided parameters:
searchForItems(query)
: call this function with a search query to trigger a new search for itemsisFromSearch
:true
when you are in search mode and the provideditems
are search items results
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
const customRefinementListWithSearchBox = instantsearch.connectors.connectRefinementList(
({ items, refine, searchForItems, isFromSearch }, isFirstRender) => {
const container = document.getElementById('brand-list');
if (isFirstRender) {
container.innerHTML = `
<div>
<input type="search" />
<ul></ul>
</div>
`;
container.addEventListener('click', ({target}) => {
const input = target.closest('input[type="checkbox"]');
if (input) {
refine(input.value);
}
});
container.addEventListener('input', ({target}) => {
const isSearchInput =
target.nodeName === 'INPUT' && target.type === 'search';
if (isSearchInput) {
searchForItems(target.value);
}
});
return;
}
if (!isFromSearch) {
container.querySelector('input[type="search"]').value = '';
}
container.querySelector('ul').innerHTML = items
.map(
({value, isRefined, highlighted, count}) => `
<li>
<label>
<input
type="checkbox"
value="${value}"
${isRefined ? 'checked' : ''}
/>
${highlighted} (${count})
</label>
</li>
`
)
.join('');
}
);
search.addWidgets([
customRefinementListWithSearchBox({
attribute: 'brand',
})
]);
Apply default value to widgets
A question that comes up frequently is “How to instantiate a refinementList
widget with a pre-selected item?”. For this use case, you can use the configure
widget.
The following example instantiates a search page with a default query of “apple” and will show a category menu where the item “Cell Phones” is already selected:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const search = instantsearch({
indexName: 'instant_search',
searchClient,
});
search.addWidgets([
instantsearch.widgets.refinementList({
container: '#category',
attribute: 'categories',
}),
// This needs to be after the refinement list has been declared
instantsearch.widgets.configure({
query: 'apple',
disjunctiveFacetsRefinements: {
categories: ['Cell Phones'],
},
}),
instantsearch.widgets.hits({
container: '#hits',
})
]);
How to provide search parameters
Algolia has a wide range of parameters. If one of the parameters you want to use isn’t covered by any widget or connector, then you can use the configure
widget.
Here’s an example configuring the distinct parameter:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const search = instantsearch({
indexName: 'instant_search',
searchClient,
});
search.addWidgets([
instantsearch.widgets.configure({
distinct: 1,
}),
instantsearch.widgets.hits({
container: '#hits',
})
]);
Dynamic update of search parameters
To dynamically update the search parameters, you should create a new custom widget.
Filter your results without widgets
Widgets already provide a lot of different ways to filter your results but sometimes you might have more complicated needs that require usage of the filters
search parameter.
Don’t use filters on a attribute already used with a widget, it will conflict.
1
2
3
4
5
search.addWidgets([
instantsearch.widgets.configure({
filters: 'NOT categories:"Cell Phones"',
})
]);
Customize the complete UI of the widgets
InstantSearch.js comes with widgets that have a standardized rendering. If you feel limited by the options provided, you can go further using connectors.
Connectors are the render-less counterparts of the widgets. They encapsulate all the logic needed for making search widgets. Each one of them is specialized to make a certain type of widget.
If you want to create a type of widget that isn’t available, you should then create a custom widget.
Introduction to connectors
Anatomy of a connector
A connector is a function that will create a widget factory, which is a function that can create widget instances.
They follow the pattern:
1
(rendering, unmount?) => (widgetParameters) => Widget
In practice, creating a new custom widget based on a connector would look like that:
1
2
3
4
5
6
7
8
9
10
11
const makeHits = instantsearch.connectors.connectHits(
function renderHits({ hits }, isFirstRendering) {
hits.forEach(hit => {
console.log(hit);
});
}
);
const search = instantsearch(/* options */);
search.addWidgets([makeHits()]);
Reuse connectors
Connectors are meant to be reusable, it’s important to be able to pass options to the rendering of each single widget instance when instantiating them. That’s why all the options passed to the newly created widget factory will be forwarded to the rendering.
In the following example you want to configure the DOM element that will host the widget:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const makeHits = instantsearch.connectors.connectHits(
function renderHits({ hits, widgetParams }, isFirstRendering) {
// widgetParams contains all the option used to call the widget factory
const container = widgetParams.container;
$(container).html(hits.map(hit => JSON.stringify(hit)));
}
);
const search = instantsearch(/* options */);
search.addWidgets([
makeHits({container: $('#hits-1')}),
makeHits({container: $('#hits-2')})
]);
When is the rendering function called?
The rendering function is called before the first search (init lifecycle step) and each time results come back from Algolia (render lifecycle step).
Depending on the method you are relying on to render your widget, you might want to use the first call to create the basic DOM structure (like when using vanilla JS or jQuery).
To be able to identify at which point of the lifecycle the rendering function is
called, a second argument isFirstRendering
is provided to the rendering function.
This parameter is there to be able to only do some operations once, like creating the basic structure of the new widget once. The latter calls can then be used to only update the DOM.
1
2
3
4
5
6
7
8
9
10
11
12
13
const makeHits = instantsearch.connectors.connectHits(
function renderHits({ hits }, isFirstRendering) {
if (isFirstRendering) {
// Do some initial rendering
}
// Do the normal rendering
}
);
const search = instantsearch(/* options */);
search.addWidgets([makeHits()]);
When is the unmount function called?
The unmount function is called when you remove a widget.
When search.removeWidgets([widget])
is called, InstantSearch.js cleans up the internal
data of the widget, and calls the unmount
function to clean up the DOM.
1
2
3
4
5
6
7
8
9
const makeHits = instantsearch.connectors.connectHits(
function renderHits({ hits, widgetParams }, isFirstRendering) {
const container = widgetParams.container;
$(container).html(hits.map(hit => JSON.stringify(hit)));
},
function unmount() {
$("#hits").remove();
}
);
Customize widgets hands-on
InstantSearch.js comes bundled with a set of 15+ UI components. Each of them has options to manipulate CSS classes or even modifying part of the HTML output (templates).
To go a step further in terms of customization, InstantSearch.js offers connectors that contain the logic of the widgets without their rendering.
A custom menu with jQuery
This example shows you how to create a new custom widget using connectMenu
connector. It covers,
step by step, how to write a render function used by the connector.
For simplicity, custom widgets use jQuery to manipulate the DOM.
The first three steps focus on implementing the rendering function and then connecting it to InstantSearch.
Set up the DOM
Since these examples use jQuery, you only want to update the changing parts of the markup at every render.
To help you to do that, the connectors API provides the isFirstRendering
boolean as second argument of the render function. You can leverage this to insert the initial markup of your custom widget.
1
2
3
4
5
6
7
8
9
10
const customMenuRenderFn = (renderParams, isFirstRendering) => {
if (isFirstRendering) {
// insert needed markup in the DOM
// here we want a `<select></select>` element and the title
$(document.body).append(`
<h1>My first custom menu widget</h1>
<select></select>
`);
}
}
If you use a rendering library such as React, you can omit this part because React will compute this for you.
Display the available dropdown options
Then, on every render, you want to update and insert the available menu items as <option>
DOM nodes:
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
const customMenuRenderFn = (renderParams, isFirstRendering) => {
if (isFirstRendering) {
$(document.body).append(`
<h1>My first custom menu widget</h1>
<select></select>
`);
}
// `renderParams` is an object containing all the information
// you need to create a custom widget.
const items = renderParams.items;
// Transform `items[]` to HTML markup:
// each item comes with a `value` and `label`, it will also have a boolean to true
// called `isRefined` when the current menu item is selected by the user.
const optionsHTML = items.map(({value, isRefined, label, count}) => `
<option value="${value}" ${isRefined ? ' selected' : ''}>
${label} (${count})
</option>`
);
// then replace the content of `<select></select>` node with the new menu items markup.
$(document.body).find('select').html(optionsHTML);
}
Now all the menu options are displayed on the page but nothing is updated when the user selects a new option. To fix that, connect the dropdown to the search.
Make dropdown interact with the search results
The menu connector comes with a refine()
function in the first argument renderParams
object.
You need to call this refine()
function every time a user select another option to refine the search results:
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
const customMenuRenderFn = (renderParams, isFirstRendering) => {
if (isFirstRendering) {
$(document.body).append(`
<h1>My first custom menu widget</h1>
<select></select>
`);
// We will bind the `<select>` change event on first render
// because we don't want to create new listeners on every render
// for potential performance issues:
const refine = renderParams.refine;
// we will use `event.target.value` to identify
// which option is selected and then refine it:
$(document.body).find('select').on('change', ({target}) => {
refine(target.value);
});
}
const items = renderParams.items;
const optionsHTML = items.map(({value, isRefined, label, count}) => `
<option value="${value}" ${isRefined ? 'selected' : ''}>
${label} (${count})
</option>
`);
$(document.body).find('select').html(optionsHTML);
}
Now every time a user selects a new option in the dropdown menu, it triggers a new search to refine the search results.
Mount the custom menu dropdown widget on your page
You’ve just written the render function and can now use it with the menu connector. This will create a new widget factory for the custom dropdown widget.
Use this factory in your search:
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
const customMenuRenderFn = (renderParams, isFirstRendering) => {
if (isFirstRendering) {
$(document.body).append(`
<h1>My first custom menu widget</h1>
<select></select>
`);
const refine = renderParams.refine;
$(document.body).find('select').on('change', ({target}) => {
refine(target.value);
});
}
const items = renderParams.items;
const optionsHTML = items.map(({value, isRefined, label, count}) => `
<option value="${value}" ${isRefined ? 'selected' : ''}>
${label} (${count})
</option>
`);
$(document.body).find('select').html(optionsHTML);
}
// Create a new factory of the custom menu select widget:
const dropdownMenu = instantsearch.connectors.connectMenu(customMenuRenderFn);
// Instantiate custom widget and display it on the page.
// Custom widgets that are created with connectors accepts
// the same options as a built-in widget, for instance
// the menu widget takes a mandatory `attribute` option
// so we have to do the same:
search.addWidgets([
dropdownMenu({
attribute: 'categories'
})
]);
This example works on a single DOM element, which means that you won’t be able to re-use it for another attribute.
Make connectors reusable
Connectors are meant to be reusable, it’s important to be able to pass options to the rendering of each single widget instance when instantiating them.
That’s why all the options passed to the newly created widget factory will be forwarded to the rendering.
Now update your custom render function to configure the DOM element where the widget is mounted and also the title:
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
const customMenuRenderFn = (renderParams, isFirstRendering) => {
// widgetParams contains all the original options used to instantiate the widget on the page.
const container = renderParams.widgetParams.containerNode;
const title = renderParams.widgetParams.title || 'My first custom menu widget';
if (isFirstRendering) {
// replace `document.body` with the container provided by the user
// and also the new title
$(container).append(`
<h1>${title}</h1>
<select></select>
`);
const refine = renderParams.refine;
$(container).find('select').on('change', ({target}) => {
refine(target.value);
});
}
const items = renderParams.items;
const optionsHTML = items.map(({value, isRefined, label, count}) => `
<option value="${value}" ${isRefined ? 'selected' : ''}>
${label} (${count})
</option>
`);
$(container).find('select').html(optionsHTML);
}
const dropdownMenu = instantsearch.connectors.connectMenu(customMenuRenderFn);
// Now you can use the dropdownMenu at two different places in the DOM:
// (since they use the same `attribute` they will display the same options)
search.addWidgets([
dropdownMenu({
attribute: 'categories',
containerNode: '#first-dropdown',
}),
dropdownMenu({
attribute: 'categories',
containerNode: '#second-dropdown',
})
]);
These steps introduced a way to provide custom parameters:
- a DOM container
- a title