React Autosuggest Search With Google Firestore
A seasoned developer gives a tutorial on how to create a custom autosuggest search feature for your site using React and FirestoreDB.
Join the DZone community and get the full member experience.
Join For FreeSearch with autocomplete is a standard feature in most apps. It is used for searching through a catalog of resources such as products, services, airports, hotels, cars, posts, etc. This capability does not come built-in with Google Firestore DB. Firestore DB doesn't support keystroke search or native indexing for text fields in documents. Furthermore, loading an entire collection on the client or a cloud function just to search on certain fields is not prudent. It can cause performance degradation as well as increase the overall cost via expensive queries since Firestore’s pricing model is based on a per-document read model.
Firebase recommends using a third-party service like Algolia or Elasticsearch to enable full text search or keystroke search for Firestore data. Integration with Algolia requires configuring the Algolia client with your App ID and API key. Alogia is also a paid service with very limited options in the free plan. If you don’t want to go through the overhead of setting up and paying for Algolia or Elasticsearch, a simple workaround for keystroke/typeahead search can be implemented on Cloud Firestore using searchable data structures and some special data querying methods.
Before we get into it, this article assumes you have a basic understanding of working with React apps and have some experience working with Google Firebase and Firestore DB. If not, I strongly encourage doing some tutorials on how to build React apps as well as dipping your feet in Google Firebase and Firestore. Onwards to the article then.
Now, let’s suppose we want to implement a Country autocomplete search component as shown below.
To build this Autocomplete search dropdown, we will use the following in our React app:
- List of countries in the dropdown data sourced from countries.json, provided here - https://gist.github.com/amitjambusaria/b9adebcb4f256eae3dfa64dc9f1cc2ef
- React Autosuggest - https://react-autosuggest.js.org
- Google Firestore database
React Autosuggest Setup
The dropdown options are the countries are not preloaded on the client side. This is done to avoid overloading the DOM with thousands of options, which can lead to sluggishness and performance degradations. Instead, with each keystroke, a network call is made with a search query to fetch a filtered subset of options.
As seen above, with each keystroke, a GET call is made to the Firestore database with the search keywords. The front-end React Autosuggest component has a prop called onSuggestionsFetchRequested
, wherein you can pass a method that can fetch the search-specific options from the server; in our case, data/options will be directly fetched from the Firestore database. The following are some of other key props that come with React Autosuggest:
Prop |
Type |
Required |
Description |
Array |
✓ |
These are the suggestions that will be displayed. Items can take an arbitrary shape. |
|
Function |
✓ |
Will be called every time you need to recalculate suggestions. |
|
Function |
✓ |
Will be called every time you need to set suggestions to |
|
Function |
✓ |
Implement it to teach Autosuggest what should be the input value when a suggestion is clicked. |
|
Function |
✓ |
Use your imagination to define how suggestions are rendered. |
|
Object |
✓ |
Pass through arbitrary props to the input. It must contain |
Click here for the full list of props. Given below is the front-end search component that leverages React Autosuggest.
x
import React, { FC, useState, useEffect, useCallback, useMemo, ChangeEvent } from 'react';
import Autosuggest from 'react-autosuggest';
import 'vendors/react-autosuggest.css';
import { useDatastore } from 'auth.provider';
import { Country, CountryService } from 'services/country.service';
export type onLocationSelect = (country: Country) => void;
type LocationAutocomplete = {
uid: string;
text: string;
initialValue?: string;
placeholder?: string;
onSelect: onLocationSelect;
};
const LocationAutosuggest = Autosuggest as new () => Autosuggest<Country>;
export const LocationAutocomplete: FC<LocationAutocomplete> = ({
uid,
text,
initialValue = '',
placeholder = 'Enter country name',
onSelect,
}) => {
const store = useDatastore();
const service = new CountryService(store);
const [value, setValue] = useState(initialValue);
const [suggestions, setSuggestions] = useState([] as Country[]);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
const onChange = useCallback(
(_: ChangeEvent, { newValue }: { newValue: string }) => {
setValue(newValue);
},
[setValue],
);
const selectSuggestion = useCallback(
(country: Country) => {
onSelect(country);
return country.name;
},
[onSelect],
);
const clearSuggestions = useCallback(() => {
setSuggestions([]);
}, [setSuggestions]);
const fetchSuggestions = useCallback(
({ value }: { value: string }) => {
if (value.length > 1) {
service.fetchCountries(value).then(setSuggestions).catch(console.error);
}
},
[service],
);
const renderSuggestion = useCallback((country: Country) => {
return (
<div tw="flex items-center text-sm">
<img src={country.flag} alt="" tw="w-6 rounded-sm mr-4 pointer-events-none" />
<span>{country.name}</span>
</div>
);
}, []);
const inputProps = useMemo(() => ({ onChange, placeholder, value }), [onChange, placeholder, value]);
return (
<>
<label tw="block mb-1" htmlFor={uid}>
{text}
</label>
<LocationAutosuggest
getSuggestionValue={selectSuggestion}
inputProps={inputProps}
multiSection={false}
onSuggestionsClearRequested={clearSuggestions}
onSuggestionsFetchRequested={fetchSuggestions}
renderSuggestion={renderSuggestion}
suggestions={suggestions}
/>
</>
);
};
Cloud Firestore Setup
Keystroke search is not supported natively by Firestore. To get around this limitation, we will have to seed the search options in a creative way. This solution is ideal when you want to search on just one or two properties of your document and don’t want to invest in paid services like Algolia or Elastic Search.
In our app, since we are searching for countries, we will have to seed the Country Collection in our Firestore DB. But simply seeding the data is not enough. For type-ahead autocomplete we need the ability to search the document based on the country “name” property. Loading the entire collection and performing the search directly on the client-side is not feasible, because, depending on the size of the collection, it could cause slowness. It can also get very expensive since Firstore’s pricing model is based on a per document read model, so when you load an entire collection for each user the costs can quickly add up.
The answer lies in creating a searchableKeywords
object for the field we want to search on. In our app, the country "name" string will be broken into substring elements and stored as an Array on each individual document. We can then query Firestore on these substrings. For example, the searchableKeywords
for the country “Canada” would be:
xxxxxxxxxx
[
0 => “c”
1 => “ca”
2 => “can”
3 => “cana”
4 => “canad”
5 => “canada”
]
The searchableKeywords
Array is created using a seeding script. This script is meant to be run as Admin against your Firestore DB. When executed, the generateKeywords
method (line 29) computes the searchableKeywords
Array with the country name substrings. This Array is added to each Country document. Finally, the entire Country Collection is committed to Firestore (line 53-64).
xxxxxxxxxx
import admin from 'firebase-admin';
import countries from './data/countries.json';
const ENV = process.env;
const serviceAccountConfig = {
type: ENV.SA_TYPE,
project_id: ENV.SA_PROJECT_ID,
private_key_id: ENV.SA_PRIVATE_KEY_ID,
private_key: ENV.SA_PRIVATE_KEY,
client_email: ENV.SA_CLIENT_EMAIL,
client_id: ENV.SA_CLIENT_ID,
auth_uri: ENV.SA_AUTH_URI,
token_uri: ENV.SA_TOKEN_URI,
auth_provider_x509_cert_url: ENV.SA_AUTH_PROVIDER_X509_CERT_URL,
client_x509_cert_url: ENV.SA_CLIENT_X509_CERT_URL,
} as admin.ServiceAccount;
admin.initializeApp({
credential: admin.credential.cert(serviceAccountConfig),
databaseURL: ENV.REACT_APP_DATABASE_URL
});
const firestore = admin.firestore();
const batch = firestore.batch();
// Ex: country name "India" will return ['i', 'in', 'ind', 'indi, 'india']
// This is needed for full-text search in country autocomplete
// TODO - convert this into clound function (on doc create) or use a 3rd party service for locations
const generateKeywords = (countryName: string) => {
const wordArr = countryName.toLowerCase().split(' ');
const searchableKeywords = [];
let prevKey = '';
for (const word of wordArr) {
const charArr = word.toLowerCase().split('');
for (const char of charArr) {
const keyword = prevKey + char;
searchableKeywords.push(keyword);
prevKey = keyword;
}
prevKey = '';
}
return searchableKeywords;
};
const countriesCollection = firestore.collection('countries');
countries.forEach((country) => {
const searchableKeywords = generateKeywords(country.name);
batch.set(countriesCollection.doc(`${country.code}_${country.region}`), {
country,
searchableKeywords,
visibility: 'public',
});
});
batch
.commit()
.then(() => {
console.log('Collection successfully written!');
})
.catch((error) => {
console.error('Error writing collection: ', error);
});
Once the searchableKeywords
are in place, you can query it using the array-contains
Firestore operator. When the user types “can” in the search field, all Countries which have “can” in their searchableKeywords
Array will be returned as autocomplete suggestions. Given below is the query to fetch suggestions.
xxxxxxxxxx
export class CountryService {
constructor(private store: firebase.firestore.Firestore) {}
public fetchCountries = async (query: string) => {
const { docs } = await this.store
.collection('countries')
.where('searchableKeywords', 'array-contains', query.trim().toLowerCase())
.get();
return docs.map((doc) => doc.data() as Country);
};
public fetchCountryByCode = async (query: string) => {
const { docs } = await this.store.collection('countries').where('code', '==', query.toUpperCase()).get();
return docs[0].data() as Country;
};
}
Conclusion
This approach for autocomplete keystroke search is very easy to setup and maintain in Firestore. It is pretty barebones and not as feature-rich as Algolia or Elasticsearch. Another drawback is that the user has to enter their search term correctly and it does not handle spelling errors. Having said that, if your needs are simple and you want something for free, this can be a quick and elegant solution for your app.
Opinions expressed by DZone contributors are their own.
Comments