Guides / Building Search UI / UI & UX patterns

Geographical search with React InstantSearch

This is the React InstantSearch v7 documentation. React InstantSearch v7 is the latest version of React InstantSearch and the stable version of React InstantSearch Hooks.

If you were using React InstantSearch v6, you can upgrade to v7.

If you were using React InstantSearch Hooks, you can still use the React InstantSearch v7 documentation, but you should check the upgrade guide for necessary changes.

If you want to keep using React InstantSearch v6, you can find the archived documentation.

You can use the geographical search capabilities of Algolia with React InstantSearch by creating a custom widget based on the useGeoSearch() Hook. This Hook isn’t tied to any map provider, so you can select and implement any solution.

This guide uses Leaflet as the map provider through its React wrapper, React Leaflet. To learn more about loading and using leaflet, visit the Leaflet documentation.

Dataset

To follow along, you can use a dataset with more than 3,000 airports. You can download the dataset on GitHub. See Importing from the dashboard to learn how to upload the dataset to Algolia.

1
2
3
4
5
6
7
8
9
10
11
12
{
    "objectID": "3797",
    "name": "John F Kennedy Intl",
    "city": "New York",
    "country": "United States",
    "iata_code": "JFK",
    "links_count": 911,
    "_geoloc": {
        "lat": 40.639751,
        "lng": -73.778925
    }
}

To be able to display hits on the map, the latitude and longitude is stored for each airport. To support location-based filtering and sorting, the latitude and longitude should be stored in the _geoloc attribute.

Configure index settings

When displaying on a map, you still want the relevance of your search results to be good. To achieve that, you need to configure:

  • Searchable attributes—support searching by: name, city, country and iata_code.
  • Custom ranking—use the number of other connected airports links_count as a ranking metric. The more connections the better.
1
2
3
4
$index->setSettings([
  'searchableAttributes' => ['name', 'city', 'country', 'iata_code'],
  'customRanking' => ['desc(links_count)']
]);

Display hits on the map

Add the Leaflet map container to your React InstantSearch app. By default, the map container has height 0, so make sure to set an explicit height, for example, using CSS.

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
// App.tsx
import React from 'react';
import algoliasearch from 'algoliasearch/lite';
import { InstantSearch, SearchBox } from 'react-instantsearch';
import { MapContainer, TileLayer } from 'react-leaflet';

const searchClient = algoliasearch(
  'latency',
  '6be0576ff61c053d5f9a3225e2a90f76',
);

export function App() {
  return (
    <InstantSearch searchClient={searchClient} indexName="airports">
      <SearchBox placeholder="Search for airports..." />
      <MapContainer
        style={{ height: '500px' }}
        center={[48.85, 2.35]}
        zoom={10}
      >
        <TileLayer
          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        />
      </MapContainer>
    </InstantSearch>
  );
}

Next, to populate the map with markers, create a custom widget for React InstantSearch, using the useGeoSearch() Hook you created before.

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
// Airports.tsx
import React from 'react';
import { Marker, Popup } from 'react-leaflet';
import { useGeoSearch } from 'react-instantsearch';

type Airport = {
  name: string;
  city: string;
  country: string;
  iata_code: string;
  links_count: number;
}

export function Airports() {
  const {
    items,
  } = useGeoSearch<Airport>();

  return (
    <>
      {items.map((item) => (
        <Marker
          key={item.objectID}
          position={item._geoloc}
        >
          <Popup>
            <strong>{item.name}</strong>
            <br />
            {item.city}, {item.country}
          </Popup>
        </Marker>
      ))}
    </>
  );
}

Finally, import this widget and add it inside <MapContainer>. You should now see markers representing the airports locations.

Moving the map

To add some interactivity, you can detect users interactions and update the list of airports to match the new viewable area of the map.

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
// ...
import { Marker, Popup, useMapEvents } from 'react-leaflet';

export function Airports() {
  const {
    items,
    refine: refineItems,
  } = useGeoSearch();

  const onViewChange = ({ target }) => {
    refineItems({
      northEast: target.getBounds().getNorthEast(),
      southWest: target.getBounds().getSouthWest(),
    });
  };

  const map = useMapEvents({
    zoomend: onViewChange,
    dragend: onViewChange,
  });

  return (
    /* ... */
  );
}

Reacting to search query updates

In the current form, if you type a query in the search box, for example, “Italy”, the markers disappear. That’s because the list of markers now show all the airports in Italy, but the map is still in its initial location.

To move the map when the search query changes, use the useSearchBox() Hook to retrieve the current query, and move the map programmaticaly when it changes.

To not interfere with the manual movement of the map, you need to clear the boundaries refinement when a query changes. In the onViewChange event handler, you also need to reset the query and set a flag that instructs the application to not move the map programmatically in this case.

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
// ...
import { useState } from 'react';
import { useSearchBox } from 'react-instantsearch';

export function Airports() {
  const { query, refine: refineQuery } = useSearchBox();
  const {
    items,
    refine: refineItems,
    currentRefinement,
    clearMapRefinement,
  } = useGeoSearch();

  const [previousQuery, setPreviousQuery] = useState(query);
  const [skipViewEffect, setSkipViewEffect] = useState(false);

  // When users move the map, clear the query if necessary to only
  // refine on the new boundaries of the map.
  const onViewChange = ({ target }) => {
    setSkipViewEffect(true);

    if (query.length > 0) {
      refineQuery('');
    }

    refineItems({
      northEast: target.getBounds().getNorthEast(),
      southWest: target.getBounds().getSouthWest(),
    });
  };

  const map = useMapEvents({
    zoomend: onViewChange,
    dragend: onViewChange,
  });

  // When the query changes, remove the boundary refinement if necessary and
  // center the map on the first result.
  if (query !== previousQuery) {
    if (currentRefinement) {
      clearMapRefinement();
    }

    // `skipViewEffect` allows us to bail out of centering on the first result
    // if the query has been cleared programmatically.
    if (items.length > 0 && !skipViewEffect) {
      map.setView(items[0]._geoloc);
    }

    setSkipViewEffect(false);
    setPreviousQuery(query);
  }

  return (
    /* ... */
  );
}