This is the second article in our 3-part live-coding series on building a Store Locator. Make sure you check out Building a Store Locator – Part 1 and Building a Store Locator – Part 3.
These days, ecommerce shoppers expect convenience and want the physical and online worlds to mesh, allowing them to conduct their business on whatever channel they want. This is what a Store Locator is built for, so that users can:
Despite that, we observe that too many websites don’t offer an elegant way for their users to find their “nearest store,” whether for testing or purchasing offline, or for picking up a purchase made online.
Building a store locator may seem complex as it requires implementing a geo search, address/POI (Point of Interest) search, and displaying results on a map. To make it as easy as possible for everyone to follow along, we have decided to do a series of three Live Coding Sessions followed by three blog posts. During these events, we’ve decided to create a responsive Store Locator in React using:
Everything is wrapped using React v17, React InstantSearch, and the Autocomplete UI library to build a modern search and discovery experience in little time.
Note: In Part 1 we created all required accounts (Clever cloud and Algolia), we indexed the data, and configured the Algolia index. Read Part 1
To quickly find the nearest store from our current location or a given address input, we’re going to leverage the Algolia GeoSearch feature, along with React InstantSearch , to display the found stores on a list view and on a map view (using Mapbox GL).
Note: You can find in the project’s Github repository the code obtained at the end of part 1.
React InstantSearch is an open source, production-ready UI library for React (also available in VanillaJS, Vue, Angular, Swift and Kotlin) that lets you quickly build a search interface in your front-end application.
Its goal is to help you implement awesome search experiences as smoothly as possible by providing a complete search ecosystem. InstantSearch tackles an important part of this vast goal by providing front-end UI components called “widgets” that you can assemble into unique search interfaces – such as:
In our case, we’ll leverage some of the existing widgets such as infiniteHits and RefinementList, and we’ll build a custom widget in order to display the results on a map powered by Mapbox. To do so, we’ll extend the default GeoSearch widget, which uses Google Maps by default, but can be extended thanks to the connectGeoSearch connector.
Note: By default, React InstantSearch is compatible with server-side rendering and has full routing capabilities.
To add InstantSearch to our app, let’s install the required packages.
yarn add algoliasearch react-instantsearch-dom
We’ll also add react-instantsearch types, for TypeScript compatibility and IDE support
yarn add -D @types/react-instantsearch
That’s all we’ll need for this part of this guide.
Let’s first grab some information from Algolia’s dashboard.
On the Algolia Dashboard, click on the Platform icon (the last icon in the sidebar) and select API Keys to copy your Search-OnlyAPI key and the Application ID.
Now, let’s create a file named .local.env
. We’ll store our environment variables in this file. These variables will be injected into our application at build time,so don’t forget to restart your local development server after creating this file.
Add the following to the .local.env
file :
REACT_APP_ALGOLIA_APP_ID=”myAppId” REACT_APP_ALGOLIA_API_KEY=”xxxxx” REACT_APP_ALGOLIA_INDEX_NAME=”myIndiceName”
Note: If environment variables are new to you, there’s plenty of extremely good resources like: Working with Environment Variables in Node.js written by my friend Dominik – lead developer evangelist at Twilio – that can help you get quickly up to speed on the topic. Another good example focuses on adding custom environment variables in Create React App apps.
Now we’ve installed the required packages, let’s add React InstantSearch to our app.The first step is to import InstantSearch at the top of our file.
import { InstantSearch, } from 'react-instantsearch-dom';
Then, we can add the InstantSearch components into our code:
As you can see, it takes two parameters:
indexName
is the name of your Index you’ve put in Algolia’s dashboard.searchClient
is an instance of AlgoliaSearch, the JavaScript client library for the Algolia Search API, which will perform all the requests. As we want to keep things as simple as possible, we’ll add a separate file with this instance (moreover, we’ll need it in the third video and keeping things separated is always a good practice in software engineering).src/lib/algoliaClient.ts
import algoliaSearch from 'algoliasearch'; const indexName = process.env.REACT_APP_ALGOLIA_INDEX_NAME; const searchClient = algoliaSearch( process.env.REACT_APP_ALGOLIA_APP_ID as string, process.env.REACT_APP_ALGOLIA_API_KEY as string ); export { indexName, searchClient };
Now we have our client, it’s time to use it in our InstantSearch instance.
We’ll use the <Hits> widget to display the results returned by Algolia.
The Hits
component takes only one prop : hitComponent
, a function with an item as an argument which should return JSX. This JSX will be rendered in the app.
<Hits<Hit> hitComponent={ ({ hit }) => ( <StoreComponent key={hit.objectID} onClick={(hit) => handleClick(hit)} hit={hit} currentStore={currentStoreCoordinates} /> )}/>
You can return any JSX you want, but we’ve created our own component, which is nothing more than a div
with a bit of CSS.
Let’s have a deeper look into its props :
src/lib/comparePosition.ts
export const comparePosition = ( position1: [number, number], position2: [number, number] | null ): boolean => { return position2 ? position1[0] === position2[0] && position1[1] === position2[1] : false; };
Now that we have created this component, we should see a nice list of Spencer & Williams stores on the side of our app!
But the truth is: it’s not really usable. Of course, it does what we asked for (sorting by popularity, but as I’m living in Northern France (close to Lille and Belgium – yummy Chocolate and good beers), I absolutely don’t care about Atlanta’s shop.
So let’s add geosearch capabilities so it can show the most popular stores around me!
By default, Algolia offers the possibility to perform a geo search without passing any latitude/ longitude but still returning the nearest hits. How? It relies on your IP address you used to call the Algolia API.Note: For people in EU, as per July 2021, an IP address is not considered PII (Personally identifiable information, DCP for “Donnée à caractère personnelle” in French) as it doesn’t precisely locate the user,meaning that are not required to warn the user that you’re using itTo tell InstantSearch that we want to use `aroundLatLngViaIP`, we use the Configure component
Note: The <Configure/> component can take any SearchParameter.e’ll see more of them in the next episode.
Now, you’ll see that the list displays shops near your location. The easiest way to experiment with this is through a VPN.
Now we have this list of nearby shops, let’s add a nice Refinement List to let us filter on the type of services.
Unlike the Hits widget, the RefinementList component only has one prop, which is used to pass the name of the facet attribute we want to use, which we have configured in the Algolia Dashboard in part 1.
Algolia is providing a default theme in a separate package. To use it, you’ll need to add the instantsearch.css stylesheet, but in our example, I wanted to explore with you a custom TailwindCSS theme using the @apply directive which allows you to style any CSS selector with TailwindCSS’ predefined class names like so :
.ais-RefinementList ul { @apply flex font-normal text-sm flex-wrap gap-2 my-4; } .ais-RefinementList ul li { @apply rounded bg-purple-200 px-2 py-1; } .ais-RefinementList ul input { display: none; } .ais-RefinementList-labelText { @apply font-medium text-purple-900; } .ais-RefinementList-count { @apply font-black text-white p-1 px-1.5 bg-purple-400 rounded-full; } .ais-RefinementList-item--selected { @apply border-2 border-purple-800; } .aa-Panel mark { color: #5468ff; background: none; }
Once you’ve added these styles,more or less the refinement list widget should look like this:
If you’ve followed along, the app should look like this at this stage:
A header, a list on the side, and ¾ of empty space. It’s now the time to fill this space with a beautiful map !
There are dozens of map providers, some are really well known :
By default, Algolia’s React InstantSearch library has a built-in widget for Google Maps. Still, to show you how to integrate any other map provider, we’ll build our new GeoSearch widget using Mapbox.com as they offer strong React and TypeScript support along with worldwide map coverage.
Mapbox has a huge field advantage in our context: a pretty well-furnished API we will use in the third episode of this series, plus the fact that they are using an open source stack :
So switching from Mapbox to another provider should not be painful.
After creating your Mapbox account, you should see something similar. Copy your Default Public Token
, and copy it to your .local.env file:
REACT_APP_MAPBOX_TOKEN=pk.your_token_here
Now, let’s install the Mapbox wrapper by typing in the terminal
yarn add react-mapbox-gl mapbox-gl
yarn add -D @types/mapbox-gl
Next, let’s create a MapComponent directory under Components, and add a MapComponent.tsx file.
Import the required packages, and scaffold the class component:
import React, { Component } from 'react'; import ReactMapboxGl, { ZoomControl, Marker } from 'react-mapbox-gl'; import { Map } from 'mapbox-gl'; interface IMapState { lat: number; lng: number; zoom: number; } interface IMapProps {} // Required to init the map const ReactMap = ReactMapboxGl({ accessToken: process.env.REACT_APP_MAPBOX_TOKEN as string, }); class MapComponent extends Component<IMapProps, IMapState> { map: any; state = { lat: 30.5, lng: 50.5, zoom: 9, }; render() { return ( <div className={'h-full w-full relative'}> <div className={'h-full w-full'}> <ReactMap ref={(e) => { this.map = e.state.map; }} {/* This is the style, we’ll use the light one but you can try street-v10 */} style="mapbox://styles/mapbox/light-v10" containerStyle={{ height: '100%', width: '100%', position: 'relative', display: 'block', }} > <> //This adds a little +/- control to zoom in / out <ZoomControl position={'bottom-right'} /> </> </ReactMap> </div> </div> ); } } export default MapComponent;
We just added a full-size map thanks to react-mapbox-gl.
By injecting your newly created component in the App.tsx, you should see something like this, so it’s now time to add our markers!
Eh… no ! That would be way too simple. To make this happen you need to add this to your craco.config.js file, which we created last week to handle PostCSS plugins.
styles:{ //...postCSS stuff here }, babel: { loaderOptions: { ignore: ['./node_modules/mapbox-gl/dist/mapbox-gl.js'], }, },
The mapbox-gl library on npm already is transpiled, so we need to tell babel (our transpiler) to not transpile mapbox-gl.js.
Now we show the map, we’ll need to add its ‘dynamic’ behavior.
It’s now time to connect the map to our React InstantSearch instance. To do this, we’ll leverage the connectGeoSearch, a higher-order component, exposed in React InstantSearch.
Let’s import the required plugins:
import { connectGeoSearch } from 'react-instantsearch-dom';
import type { GeoSearchProvided } from 'react-instantsearch-core';
The first imports the library itself, the second imports the types required by TypeScript and IntelliSense.
First we’ll slightly update our MapComponent class declaration:
class MapComponent extends Component<GeoSearchProvided & IMapProps, IMapState>
We’ll slightly change the way we export the component:
export default connectGeoSearch(MapComponent)
;
After this, nothing will change in our map rendering yet but if we open React DevTools and search for our MapComponent, something will have changed…
We see that our MapComponent now has a hits prop containing all our Algolia’s index records. Now, we can focus on displaying those on the map as markers.
Let’s add a marker array, marker: []
, in our state interface that we initialize as an empty array before populating the coordinates of each of our stores..
To update the displayed markers anytime a new JSON response is received from Algolia we’ll use React’s life cycle method componentWillReceiveProps
in which we’ll add our logic related to adding markers to the map.
componentWillReceiveProps(nextProps){ // Let’s grab hits from nextProps and let’s compare if the two are not empty and differents from those already in props. const { hits } = nextProps; if (hits.length && hits !== this.props.hits) { // If so let’s grab coordinates from the hit and store them into an temp array const markers: [number, number][] = hits.map( ({ _geoloc }: { _geoloc: { lat: number; lng: number } }) => { return [_geoloc.lng, _geoloc.lat]; } ); // Finally let’s store this in our component state, and update the map accordingly (by centering it to the first marker) We’ll check this method in a minute, you can comment it for now. this.setState({ markers }, () => { //this.centerMapOnCoordinates(this.state.markers[0]); }); } }
To display the markers on the map, let’s create a <StoreMarker/> component in the MapComponent directory. This one has 1 property – isSelected – to change the marker if the store is selected. It’s a really simple component, so I’ll include it here.
import React from "react"; import { ReactComponent as MarkerPin } from '../../marker.svg'; import { ReactComponent as MarkerPinSelected } from '../../marker-selected.svg'; const StoreMarker: React.FC<{isSelected: boolean}> = ({isSelected}) => { return isSelected ? <MarkerPinSelected className={'shadow-lg cursor-pointer'}/> : <MarkerPin className={'shadow-lg cursor-pointer'}/>; } export default StoreMarker
Let’s now add an extra layer of complexity to our component by wrapping it in a MapboxGL <Marker/> component. It will be responsible for adding the marker to the map. It only takes one prop (for our example, but there’s a bunch more available): coordinates:
… import {Marker} from "react-mapbox-gl"; const StoreMarker: React.FC<{isSelected: boolean, coordinates: [number, number]}> = ({isSelected, coordinates}) => { return <Marker coordinates={coordinates}> {isSelected ? <MarkerPinSelected className={'shadow-lg cursor-pointer'}/> : <MarkerPin className={'shadow-lg cursor-pointer'}/>} </Marker> }
Now in the MapComponent, let’s “map“ over our marker to display them on the map!
MapComponent/MapComponent.tsx <> {this.state.markers.map((position) => { return ( <StoreMarker isSelected={false} coordinates={position}/> ); })} <ZoomControl position={'bottom-right'} /> </>
We now should have the markers rendered on our map.
It’s now time to “refine” this and center the map on the given markers. But before looking closer at centerMapOnCoordinates
let’s add another lifecycle method to avoid extraneous rendering. Here we are just checking if the two arrays of hits are different and block the rendering on the contrary.
shouldComponentUpdate(nextProps: Readonly<GeoSearchProvided>): boolean { return nextProps.hits !== this.props.hits; }
Let’s move on to centerMapOnCoordinates
. This is probably the most complicated bit of code in this week’s part.
Note: Don’t hesitate to watch the livecoding available at the top of the article in case you’d like more information about the different steps.
So let’s split this:
We play with the isUserInterraction state Boolean to avoid entering the map if the call is triggered by a user action on the map.
If it’s not, we temporarily set it to true, and center the map on the first marker (which is, per our configuration, the store with the highest popularity attribute on the viewport).
Then, we call the refine method provided by the hook, which allows us to request Algolia’s data based on the map boundaries: here north-east and south-west (top-right, and bottom-left).
centerMapOnCoordinates(coordinates: [number, number]) { if (!this.state.isUserInteraction) { const { refine } = this.props; this.setState({ isUserInteraction: true }, () => { this.map.state.map.flyTo({ essential: false, center: [coordinates[0], coordinates[1]], zoom: 7, }); refine({ northEast: this.map.state.map.getBounds().getNorthEast(), southWest: this.map.state.map.getBounds().getSouthWest(), }); }); } }
The last method needed in this map component will be used to refine the map marker when we move the map! It’s a self-explanatory method; we already used all of the methods called in its body previously.
onMapInteraction(map: Map) { if (this.state.userInteractionEnabled) { const { refine } = this.props; refine({ northEast: map.getBounds().getNorthEast(), southWest: map.getBounds().getSouthWest(), }); } }
The only new parameter here is the userInteractionEnabled
Boolean which changes based on the checkbox we’ll add next!
To handle this, and to keep our code as clear as possible, let’s create a new component in the MapComponent/ directory. We will call it SearchAsMoving once more to keep our component simple and stupid (KISS methodology). It’s a simple div with a text and a input type=”checkbox”
interface ISearchAsMoving { checked: boolean, onChange: (checked: boolean) => void } const SearchAsMoving: React.FC<ISearchAsMoving> = ({checked, onChange}) => { return ( <div> <input defaultChecked={checked} type={'checkbox'} onChange={(e) => onChange(e.target.checked) } /> Search when I move the map </div> ) } export default SearchAsMoving
We pass two props to this method, the default check status (checked) and the method we want to trigger when we change the state of the checkbox.
We can now add this to our map, under the <ReactMap/> component and we’ll be good !
<SearchAsMoving checked={this.state.userInteractionEnabled} onChange={(userInteractionEnabled) => this.setState({userInteractionEnabled})}/>
Once done let’s add the onMapInteraction
to the map itself, and let’s wrap up !
<ReactMap ref={(e) => { this.map = e?.state.map; }} style={...} containerStyle={{...}} onMoveEnd={this.onMapInteraction} >...
So based on last week work :
Stat tuned for the part 3 in which we’ll see
Note: the code of today’s session is available in the Github repository.
Clément Sauvage
Software Engineer, FreelancePowered by Algolia AI Recommendations
Clément Sauvage
Software Engineer, FreelanceAshley Huynh
Julia Seidman
Developer Educator