By this point, I'd managed to populate an Algolia search index, integrate it with Gatsby, and created a specific search page. The MVP was effectively complete, but it wasn't exactly pretty. The next step was to extend the core functionality and build out my site search capabilities.
Custom Search Components
I have a slightly irrational distrust of other people's code, especially when it's abstracted. I feel that opaque dependencies[1] become "black boxes", sections of code which information disappears into and produces something useful, but which you can't really grok, and I don't like that feeling. Algolia's search components are classic examples of black boxes: they do something incredibly useful, they work out of the box, and they can be a complete pain if you want to slightly tweak them. Don't want your search results returned as a list? Tough, that's just what the black box spits out.
Yes, you can use a slightly hacky
CSS workaround (see previous post), but that could be problematic in the future. What happens if those class names are updated in a later release? I'm left
patching a UI bug that I didn't create – no thanks! Luckily, the team at
Algolia haven't just created a bunch of neat little ways to extend
their components, they've also given us the power to utterly rewrite
them. They're still a bit of a black box, requiring certain inputs which just magically work, but at least the UI is now entirely editable. I did mess around with a bit of this last time via the hitComponent
attribute, but that still left unwanted wrapping elements. Instead, let's create some bespoke, customised components!
Custom Hits
I've already got a PostPreview
component set up, but
it's currently being used within an attribute. I want to invert that relationship and have my component as the parent. First, let's import connectHits
:
import { Highlight, connectHits } from "react-instantsearch-dom"
Next, we'll change the name of the main function to CustomHitPreview
, pass it the parent element with all returned searches (hits
) rather than each returned object (hit
), and then wrap the existing markup in a <section>
containing a mapped array:
const CustomHitPreview = ({ hits }) => { return ( <section className={styles.search_results}> {hits.map((hit, index) => { let searchResult = hit.node let type = searchResult.contentType.substring(0, searchResult.contentType.length - 1) let url = searchResult.contentType === "articles" ? "article" : searchResult.contentType === "journals" ? `journal/${searchResult.year}/${searchResult.month.toLowerCase()}` : searchResult.contentType return ( <article key={index}> <h2> <Highlight attribute="node.title" hit={hit} /> </h2> <p> <Highlight attribute="node.sanitised" hit={hit} /> </p> <p> <Link to={`${url}/${searchResult.slug}`}>Read the full {type}.</Link> Date posted: {searchResult.month} {searchResult.year} </p> </article> ) })} </section> ) }
About 90% of the original code is exactly the same. We're still defining
the layout of each returned entry, but rather than doing so within a
subcomponent, we're now in control of mapping the original query
response. That allows us to get rid of the <ul>
element entirely, which is what I wanted. It also lets us set our own class
names, so we don't need to worry about future code changes that are out of our control. Perfect.
The last thing to do is to pipe this up to the InstantSearch structure by telling it that PostPreview
effectively extends the existing Hits
component, which we can do using the following line:
export const PostPreview = connectHits(CustomHitPreview)
Custom Search Box
I used the same method to also create a new component called SearchBox
which replaces the default version from InstantSearch. The core
concepts are all the same: create a new markup structure
and export it using the connectSearchBox
function so that InstantSearch knows what core functionality should be made available. Here's what that looks like:
import React from "react" import { connectSearchBox } from "react-instantsearch-dom" import styles from "./search.module.css" const SearchBox = ({ currentRefinement, isSearchStalled, refine }) => ( <form className={styles.search_box} noValidate role="search"> <input type="search" value={currentRefinement} onChange={(event) => refine(event.currentTarget.value)} /> <button onClick={() => refine("")}> Reset query </button> {isSearchStalled ? <p>Sorry, search is stalling, please wait a moment.</p> : ""} </form> ) export const CustomSearchBox = connectSearchBox(SearchBox)
I'm still making use of some inbuilt functions – like isSearchStalled
which lets us show an error message if the response is taking a while –
but now I have full control of the structure and style of the input
method.
Except... doing so resulted in an irritating new behaviour: the page reloaded every time the refresh button is clicked or the user hit enter. That's not ideal. To fix that, I removed the default form behaviour by first adding type="button"
to the <button>
, which overrides the browser default of submit
.
Next, I changed the form submission behaviour. Because this is Gatsby, that meant using the onSubmit
hook and calling preventDefault
like so:
<form className={styles.search_box} noValidate role="search" onSubmit={(e) => e.preventDefault()}>
Notice that I also added a default action
value. You still want an action
attribute as that's just best practice, but it shouldn't do anything. I've seen a lot of people use action="#"
but that never feels quite right to me, so I prefer this method.
Update: React now throws a warning for any instance of "javascript:void(0);"
, so I did some digging around. According to the latest HTML5 spec you can now leave off the action attribute, so, for now, that's what I'm doing.
Using Custom Components
Finally, I hooked the new components up to the original Search
component, which was extremely easy. I just removed SearchBox
and Hits
from the InstantSearch import and replaced them with my own[2]:
import { PostPreview } from "./post_preview" import { CustomSearchBox } from "./search_box"
Then I just changed the layout to use them instead of the existing setup:
<InstantSearch indexName={searchIndex} searchClient={searchClient}> <CustomSearchBox defaultRefinement="" /> <PostPreview /> <PoweredBy /> </InstantSearch>
And that's it. With a little CSS applied, I had a much nicer looking search box and reset button:
Extending The Search Components
Okay, so the search bar looks half decent, it returns useful search results, and the whole thing is rigged up neatly. Next, I wanted to customise it for my own use cases.
Autoselecting Current Search Text
It's a small thing, but if I've typed in a search query, browsed around the list, and then come back to the search box again, I'd prefer to skip the refresh button and just select what's present. Lazy? Sure. Better UX? Definitely. However, I don't want the input to clear, as sometimes I might want to augment or adjust the previous search (typos, for example, or increased specificity).
React makes this a little harder in some ways, but there's a basic pattern you get used to using: create a small event
function and call that from the onClick
hook. It looks something like this (middle code removed for space):
const handleFocus = (event) => event.target.select() const SearchBox = {... <input type="search" value={currentRefinement} onChange={(event) => refine(event.currentTarget.value)} placeholder="Search archives" onClick={handleFocus} /> {...} }
Now, if there's any non-placeholder text in the search box when you click on it the previous search will be fully selected, ready to be written over or amended.
Persisting Searches
Here's a common user journey: search for something, see a result, click on it, read the article, hit back in order to return to the search results. Maybe the article didn't answer your question, maybe you clicked the link by accident, maybe you just saw multiple useful results (one can dream). Whatever the reason, it's annoying when you go back to the search page to find it reset. Well, out of the box that's what Algolia does, so I wanted to fix that.
As I'm using React, my initial thought was to, well, use React's state model. There's even a useful prop
built into InstantSearch called currentRefinement
which tracks the user input within the search box, which felt like a good starting point. Except, that kind of state is inherently restricted to the client. Sure, we can use global state or get fancy with something like React Router, but it's useful to be able to link to search results from other websites, which means being able to persist search state between both clients and time. That sounds like the job of a URL, which comes with the added bonus of just working with browser back buttons. Ideal.
Step one was to get the current search term to update the URL. I did try playing around with Algolia's own history functions, but having added almost 30 lines of code I still wasn't getting anywhere and it felt like I was just trying to reinvent a wheel. Specifically, the wheel of window.history.replaceState
. So I threw that all out and wrote this function:
const updateQuery = (query) => window.history.replaceState(null, null, `?query=${query}`)
Now I've gotta raise my hand here and say that I don't know whether using a blank state is good or bad, but it works so it'll do.
Ideally, the URL should change at the same time as the actual search value, which is done via the onChange
event hook. React can be a little funny with chaining functions within event hooks, so I had to abstract the current setup to a new function that did everything I wanted:
const userSearch = (event, refine) => { refine(event.currentTarget.value) updateQuery(event.currentTarget.value) }
With an <input>
element of:
<input type="search" value="{currentRefinement}" onchange="{(event)" ==""> userSearch(event, refine)} placeholder="Search archives" onClick={handleFocus} />
But now the reset button was broken, so I had to modify its onClick
event in the same way and create a new function that reset both the URL and the search box:
const resetSearch = (refine) => { refine("") updateQuery("") } {...} <button onClick={() => resetSearch(refine)} type="button" title="Reset search"><span role="img" aria-label="Reset">🔄</span></button>
Great, now whatever you type into the search box is instantly visible in the query parameter added to the URL; anything already present is just overwritten. I could test persistence by typing something into the box, then clicking any link on the page and instantly hitting the back button; the URL parameter is still there[3].
The next step was to retrieve that URL parameter when the page initially loads and set it as the search value, so that links from external sources render correctly. Luckily, Algolia gives us the defeaultRefinement
prop, so we just need to grab the URL and pass it to that. Rather than prop drilling Gatsby's inbuilt location
, I pulled the globalHistory
element out of Reach Router, queried the search
attribute, and then stripped away the unwanted bits of the string, like so[4]:
import { globalHistory } from "@reach/router" export default function Search() { let urlQuery = globalHistory.location.search ? globalHistory.location.search.replace("?query=", "") : "" {...} }
Then all I needed to do was pass that value into defeaultRefinement
and it was good to go:
<CustomSearchBox defaultRefinement={urlQuery} />
Now, when I load a page with a URL parameter string my code will just grab it and input it to the search box. That opens up some interesting use cases, but most importantly it means I can link to search results directly. I can even use Algolia to effectively create category and tag pages, like so: Technology (category) or a11y (tag).
Filtering Results on Category
I've spent the last few weeks coming up with an information taxonomy, which is obviously massively useful for site search. Still, at this point, all that added usefulness was hidden in the search index, whereas it would be most beneficial to surface it directly on the search page. The simplest method would be to filter on category and Algolia has a specific widget for this called the RefinementList
, so that's what I used.
I imported the RefinementList
component from InstantSearch, then dropped it into my template with the required filtering attribute. I want to sort on categories, so that looks like this:
<RefinementList attribute="node.categories" />
You will need to define the attribute as a "facet" in the Algolia control panel before anything shows up, but once you do you should see something like this:
That's some pretty great initial functionality but, as tends to be the case, it looks a bit rubbish. Similar to the other InstantSearch components, I created a custom version for styling (this works the same way as the other two detailed above). I started with something pretty close to the default component:
const CategoryFilter = ({ items, refine }) => { return ( <ul className={styles.category_list}> {items.map((item) => ( <li key={item.label}> <label style={{ fontWeight: item.isRefined ? "bold" : "" }}> <input type="checkbox" onClick={(event) => refine(item.value)} /> {item.label} <span>{item.count}</span> </label> </li> ))} </ul> ) } export const CustomCategoryFilter = connectRefinementList(CategoryFilter)
Most of the elements are the same, but now that I control the CSS it was pretty easy to completely change the look and feel:
The only thing left was to connect the new filters up to the URL parameter function, so that they can be remembered and passed around as well. Except that turned out to be quite tricky. For starters, I needed two URL parameters going forward, but I didn't want one to override the other. That meant creating a new function that could return the current parameter values and split them into an array:
const getSearch = () => { let currentSearch = new URLSearchParams(window.location.search) let parameters = { query: currentSearch.get("query"), filter: currentSearch.get("filter") } return parameters }
Once that was done, I had to adapt the current updateQuery
function to work with both. Whilst I was at it, I threw in some null checks and URI encoding to prevent filters (which can contain special characters like "&") from getting themselves confused:
const updateQuery = (query, filter) => { window.history.replaceState(null, null, filter && filter.length ? `?query=${encodeURIComponent(query)}&filter=${encodeURIComponent(filter).replace(/%20/g, "+")}` : query ? `?query=${encodeURIComponent(query)}` : "?") }
Finally, I created a new function to take care of updating the filters specifically. I could probably combine this and userSearch
if needed, but it's easier for me to understand if I keep them separate for now (and yes, I had to modify userSearch
to mimic this layout too):
const userFilter = (filter, refine) => { let currentQuery = getSearch() refine(filter) updateQuery(currentQuery.query === null ? "" : currentQuery.query, filter) }
Whether a user types in the search box or selects a filter, the result is effectively the same now. Either interaction triggers a function which calls getSearch
; in turn, that determines the current URL parameters and passes them back as an array. The values of that array are then used to populate the URL itself via the updateQuery
function[5].
I'm pretty sure that I could be doing all of this with either the Algolia URL/history functions or with React state, but every attempt I've made down either route has run up against issues with the various parts getting out of sync. I imagine that's my fault, but for now this will do.
That took care of saving filter choices within the URL, but what about pulling them back out? Well I'm already fetching the location.search
value on page load, so it was pretty simple. I created a new variable for the filter values, and then ensured it was de-URI-encoded and split into an array. Oh, and I adapted the previous check the pulled back the query so that it removed filter information:
let urlQuery = globalHistory.location.search ? globalHistory.location.search.replace("?query=", "").replace(/&filter.*/, "") : "" let urlFilter = globalHistory.location.search.search("&filter=") > 1 ? decodeURIComponent(globalHistory.location.search.replace(/.*&filter=/, "").replace(/\+/g, " ")).split(",") : []
Once that was done, I could just pass it through to the category list using the same defaultRefinement
prop[6]. Lovely.
Clearing all search settings at once
When I first set up my search box I included a "refresh" button, but now I wanted it to clear my filters as well as the current query. Algolia have a widget (currentRefinements
) that does the job and that's the easiest option, so I abstracted out my existing button into a custom version of that widget and slightly modified my original function:
const resetSearch = (refine, items) => { refine(items) updateQuery("") } const ClearAll = ({ items, refine }) => { return ( <button onClick={() => resetSearch(refine, items)} type="button" title="Reset search"> <span role="img" aria-label="Reset">🔄</span> </button> ) } const ClearAllButton = connectCurrentRefinements(ClearAll)
And then, within my SearchBox component I added my new refresh button with a specific boolean attribute provided by InstantSearch:
<ClearAllButton clearsQuery />
That button now clears both the search box query and the filters on the UI, as well as updating my URL to a default blank state.
Ordering search results by date posted
The last little irritation on my list was that Algolia serves the initial Hits
component in reverse date order, so the oldest posts are always visible. I wanted the inverse behaviour, as I imagine a lot of people would, which is why I thought this would be a simple thing to do. In the end, it genuinely was simple, but I can't even begin to explain all of the weird and wonderful things I tried first 😂
Here's the actual solution: log in to your Algolia console, go to Indicies -> Configuration -> Ranking and Sorting and add a new Sort By attribute. Enter the value of your date field (for me that was node.date
) and select Descending from the dropdown. Done.
What you don't need to do is to try and override the initial search so that the hits
variable is blank, then manually populate that same variable from a customised JSON object... I mean, it does kinda work (I should know), but it's a lot simpler to just use Algolia 🤦♂️
For the sake of interest though, if you do want to go the ridiculous route, here's what you need to add to your main Search
component in order to override the initial search functionality:
const searchClient = { search(requests) { if (requests.every(({ params }) => !params.query)) { return Promise.resolve({ results: requests.map(() => ({ hits: [], nbHits: 0, nbPages: 0, processingTimeMS: 0 })) }) } return algoliaClient.search(requests) } }
You also need to amend your existing searchClient
variable to be called algoliaClient
. That will suppress all search results for blank/null queries, including initial load, so pretty useful if you want a search box which only shows responses when there are some and/or when a user interacts with it.
If, however, you want to customise the initial/blank search state, you need to create a JSON object with the desired data and stick that in your hits
array, like this:
const initSearchJSON = JSON.parse( '{"node": { "title": "", "slug": "hello-world-2","categories": ["frontend"],"tags": [],"silo": [],"contentType": "articles","sanitised": "","date": "2015-07-05T00:00:00+01:00","year": "2015","month": "July"},"_highlightResult": { "node": { "title": { "value": "A Big Deal?", "matchLevel": "none", "matchedWords": [] }, "slug": { "value": "hello-world-2", "matchLevel": "none", "matchedWords": [] }, "contentType": { "value": "articles", "matchLevel": "none", "matchedWords": [] }, "sanitised": { "value": "So for the first time in nearly seven years I officially have a website. An extremely ugly website, admittedly, but that is largely by design - honest! Regardless, it\'s definitely a website and, […]", "matchLevel": "none", "matchedWords": []}}}}' ) const searchClient = { search(requests) { if (requests.every(({ params }) => !params.query)) { return Promise.resolve({ results: requests.map(() => ({ hits: [initSearchJSON], nbHits: 0, nbPages: 0, processingTimeMS: 0 })) }) } return algoliaClient.search(requests) } }
As I say, I wouldn't recommend doing that, but I now know it works so, in case it comes in handy in the future, I figured I'd make a note of it. Obviously, rather than hand-coding the initial JSON object you would probably want to populate it from a GraphQL query or Fetch response, but I didn't get that far down the rabbit hole before finding the simpler method (luckily). Or you could just render an entirely different component if hits
is blank, but where would be the fun in that?[7]
Search Complete!
With that, I'm done... for now 😉 There are still a few additional bits and pieces I'd like to add to the search page, not least of all being a way to filter on content type (article, note, journal etc.), but for now it's working, it's tested, and it does everything I set out for it to do (and then some). That means it's time to release it, use it for a bit, and then come back to it later.
Overall, I've been really impressed with how Algolia works. There's heaps of functionality, a wide array of helper tools like InstantSearch, and detailed, well paced documentation. Plus, being able to effectively deconstruct widgets and only keep the bits you want is brilliant. I love that they allow users to get up and running very quickly, but if you want to take more control it doesn't penalise you in any way, which means you get a great user experience and a great developer experience.
I'm still not 100% comfortable with client-side rendering for something as potentially intense as search, but I need to do my own real-world testing to see how Algolia copes. If most of the actual work is done on their server, well, that's no different to any other search; if they're pushing that all to the "edge" then it's less ideal, but I'm also not sure what other options I have with the Jamstack. I do have a small niggle that there should be some way to piggyback on Craft's own search functionality, but for now I'm pretty stoked with what I've been able to bodge together 👍