If you need a quick and simple solution for address search with autocomplete and geocoding this one is for you.
To set up this project you will need to get a Mapbox API key, or token, as they call it, from their official website. You can get started for free and use the API up to a limit, which is pretty generous, at least for a prototype app. You will have up to 100,000 free Search API requests (used for address search and autocomplete suggestions) and up to 50,000 free Map API requests (used for loading/generating a map of the selected location), after which you will have to pay to continue using the service. For this reason you will be asked to enter your credit card details in order to be able to get your API key, so be prepared for that.
If you would prefer a video version of this tutorial you can watch me build the Address Search app with React and Mapbox on Youtube:
I started a new React app through Vite by running:
yarn create vite
If you don’t use yarn you can find detailed scaffolding instructions on Vite’s Getting Started page.
For this project we are going to use several additional packages - axios, mapbox-gl, react-map-gl and sass. Axios is going to be used for fetching the autocomplete suggestions and isn’t necessary if you don’t want to use it. Sass is optional as well. I use it as a personal preference, but you can do the whole project with vanila CSS if you want to, just make sure to adjust your selectors accordingly. To install all of them run:
yarn add axios mapbox-gl react-map-gl sass
or
npm install axios mapbox-gl react-map-gl sass
I am also going to use an image for the map pin, or marker, which you can grab from my GitHub repo if you want to use the same one. Make sure to put it in your /src/assets
folder.
Final peace of setup that you need to do before continuing is configuring your environment variable. If you are working with Vite like me you need to create a new file called .env.local
in your project’s root and add this property to it.
VITE_TOKEN = "YOUR_MAPBOX_TOKEN"
Make sure to replace the string with your actual Mapbox token. Your variable need to be prefixed with VITE_
, so make sure to not remove that part. Bare in mind that this will make your token visible in the client if you intend to publish this app somewhere, or add this functionality to your existing web app. To prevent the misuse of your token Mapbox has in place a couple of options, such as limiting the scope of functionalities that can be accessed with the token, and also restricting the token use to a specific URL. As the safest option you can choose to handle all of the API stuff in the backend, which is out of the scope of this tutorial. After you have your token in place we can proceed with creating components.
To start off I made a new folder called components
and in there I added three new components called AddressForm.jsx
, AutoCompleteInput.jsx
and Map.jsx
. Each component has its own .scss
file with the same name and I’ve put all of them in a new folder called styles
. To being I’ve imported AddressForm
and Map
components into App.jsx
, and added some simple state handling. Here is what the App.jsx
looks like.
// App.js
import "./App.scss";
import AddressForm from "./components/AddressForm";
import Map from "./components/Map";
import "mapbox-gl/dist/mapbox-gl.css";
import { useState } from "react";
function App() {
const [address, setAddress] = useState({
streetAndNumber: "",
place: "",
region: "",
postcode: "",
country: "",
latitude: "",
longitude: "",
});
const handleFormSubmit = (event) => {
event.preventDefault();
if (address.streetAndNumber) {
console.log("Selected address:", address);
}
};
const updateCoordinates = (latitude, longitude) => {
setAddress({ ...address, latitude, longitude });
};
return (
<div className="App">
<AddressForm
onSubmit={handleFormSubmit}
address={address}
setAddress={setAddress}
/>
{address.longitude && address.latitude && (
<Map
longitude={address.longitude}
latitude={address.latitude}
updateCoordinates={updateCoordinates}
/>
)}
</div>
);
}
export default App;
Don’t forget to import mapbox-gl/dist/mapbox-gl.css
, as this is additional styling required for the map to render properly.
We are setting our state to an object which contains all of the address properties we will be getting from the API, and we are initializing them to an empty value.
The handleFormSubmit
is an optional function which you can skip if you don’t need it. We are using it to confirm our final address has been set after selecting one of the autocomplete suggestions and submitting the form, but also after manually changing one of the inputs and then submitting.
In case you want to manually adjust the map marker position in the end, and consequently the final map coordinates - we will use the updateCoordinates
function which keeps the old state and changes just the latitude and longitude.
We will display the Map
component conditionally, only if we have the address coordinates in place. We are also passing in several props to both components.
Here is what the AddressForm
component looks like.
// AddressForm.jsx
import "../styles/AddressForm.scss";
import AutoCompleteInput from "./AutoCompleteInput";
export default function AddressForm({ address, onSubmit, setAddress }) {
const handleManualInputChange = (event, stateProperty) => {
const newAddress = { ...address };
newAddress[stateProperty] = event.target.value;
setAddress(newAddress);
};
return (
<form className="form" onSubmit={onSubmit}>
<label htmlFor="address">Address</label>
<AutoCompleteInput
setAddress={setAddress}
handleManualInputChange={handleManualInputChange}
streetAndNumber={address.streetAndNumber}
/>
<label htmlFor="city">City</label>
<input
type="text"
id="city"
placeholder="City"
value={address.place}
onChange={(event) => handleManualInputChange(event, "place")}
/>
<label htmlFor="state">State/Province/Region</label>
<input
type="text"
id="state"
placeholder="State/Province/Region"
value={address.region}
onChange={(event) => handleManualInputChange(event, "region")}
/>
<label htmlFor="postcode">Postcode</label>
<input
type="text"
id="postcode"
placeholder="Postcode"
value={address.postcode}
onChange={(event) => handleManualInputChange(event, "postcode")}
/>
<label htmlFor="country">Country</label>
<input
type="text"
id="country"
placeholder="Country"
value={address.country}
onChange={(event) => handleManualInputChange(event, "country")}
/>
<div className="buttons">
<button type="submit" className="confirm-button">
Confirm
</button>
<button
type="reset"
className="reset-button"
onClick={() =>
setAddress({
streetAndNumber: "",
place: "",
region: "",
postcode: "",
country: "",
latitude: "",
longitude: "",
})
}
>
Reset
</button>
</div>
</form>
);
}
It looks big, but it’s mostly just a form with several input fields and their labels. We are also importing our AutoCompleteInput
which we will implement next.
All inputs have an onChange
event which calls our handleManualInputChange
function which takes in the property name that needs to be manually changed in our state and sets it to the input’s value. We are also passing in this function as a prop to the AutoCompleteInput
component which will also implement it.
There is also a reset
button which just resets our address state to our default object with empty address properties. We can now continue with setting up the AutoCompleteInput
component which looks like this.
//AutoCompleteInput.jsx
import "../styles/AutoCompleteInput.scss";
import { useState } from "react";
import getPlaces from "../API/getPlaces";
export default function AutoCompleteInput({
handleManualInputChange,
setAddress,
streetAndNumber,
}) {
const [suggestions, setSuggestions] = useState([]);
const handleChange = (event) => {
handleManualInputChange(event, "streetAndNumber");
handleInputChange(event.target.value);
};
const handleInputChange = async (query) => {
const suggesions = await getPlaces(query);
setSuggestions(suggesions);
};
const handleSuggestionClick = (suggestion) => {
const streetAndNumber = suggestion.place_name.split(",")[0];
const latitude = suggestion.center[1];
const longitude = suggestion.center[0];
const address = {
streetAndNumber,
place: "",
region: "",
postcode: "",
country: "",
latitude,
longitude,
};
suggestion.context.forEach((element) => {
const identifier = element.id.split(".")[0];
address[identifier] = element.text;
});
setAddress(address);
setSuggestions([]);
};
return (
<div>
<div className="autoCompleteInputContainer">
<input
id="address"
type="text"
placeholder="Address"
value={streetAndNumber}
onChange={handleChange}
/>
<ul className="addressSuggestions">
{suggestions?.map((suggestion, index) => (
<li key={index} onClick={() => handleSuggestionClick(suggestion)}>
{suggestion.place_name}
</li>
))}
</ul>
</div>
</div>
);
}
We have an input which implements our handleManualInputChange
function, but also calls our getPlaces
function which we will use to fetch the address suggestions, which we will then store with useState
. We will map out every suggestion in a list below the input and assign to each of them an onClick
event which will handle getting all of the address properties from that suggestion and storing our newly selected address in state.
To get the streetAndNumber
we need to split suggestion.place_name
property over a comma, because only the first part before the comma is the actual street name and the address number.
Latitude and longitude are stored in suggestion.center
property which is an array with just those two values.
Rest of the properties we need to extract from suggestion.context
property, which is an array of objects which have two properties inside - id
and text
. The id
has values which correspond to our address property names, but they also have a dot followed buy a three digit number. For example, an id
might have values such as place.346
, or country.834
, or region.772
. That’s why we are splitting this value over the .
and taking the first value, which is our property name. Then we are setting that property name in our state to the text
value, which is our actual country, city etc.
Once all of the existing properties are set we update our address state, and clear our suggestions state.
In order for this to work we must now implement our getPlaces
function which will fetch our addresses.
// getPlaces.js
import axios from "axios";
export default async function getPlaces(query) {
try {
const response = await axios.get(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${query}.json`,
{
params: {
access_token: import.meta.env.VITE_TOKEN,
},
}
);
return response.data.features;
} catch (error) {
console.error("There was an error while fetching places:", error);
}
}
We are using the value from the input as our query value that will be searched through the API call. We also need to pass in our access_token
as a parameter, and return response.data.features
property, which is a list of our matching addresses. If there is an error we handle it in the catch
block.
If you go back and start typing into the AutoCompleteInput
you should get back suggestions from the API, and if you click on one of them it should get stored in our state. But, we still have one more step, and that is generating a map that points to our selected address, so let’s implement our Map
component.
// Map.jsx
import "../styles/Map.scss";
import PointerIcon from "../assets/pointer.svg";
import ReactMapGl, { Marker } from "react-map-gl";
import { useState, useEffect } from "react";
const TOKEN = import.meta.env.VITE_TOKEN;
function Map({ longitude, latitude, updateCoordinates }) {
const [viewport, setViewport] = useState({
latitude,
longitude,
zoom: 16,
});
const [marker, setMarker] = useState({
latitude,
longitude,
});
useEffect(() => {
setViewport((oldViewport) => ({
...oldViewport,
latitude,
longitude,
}));
}, [latitude, longitude]);
const handleMarkerDrag = (event) => {
const latitude = event.lngLat.lat;
const longitude = event.lngLat.lng;
setMarker({ latitude, longitude });
updateCoordinates(latitude, longitude);
};
return (
<div className="map">
<ReactMapGl
{...viewport}
mapboxAccessToken={TOKEN}
mapStyle="mapbox://styles/mapbox/streets-v12"
onMove={(event) => {
setViewport(event.viewState);
}}
>
<Marker
latitude={marker.latitude}
longitude={marker.longitude}
draggable={true}
onDragEnd={handleMarkerDrag}
>
<img className="marker" src={PointerIcon} />
</Marker>
</ReactMapGl>
</div>
);
}
export default Map;
Viewport
is a configuration object for the ReactMapGl
component. At it’s most basic setting it should contain at least your latitude
and longitude
so it knows where to center the map view. If you remember, we are rendering our Map
component conditionally only if there are existing coordinates in our state, so the default state values for viewport
should be properly handled through our props. The zoom
property handles the zoom level of the map, which you might adjust based on your preferences and use-case.
We are also handling marker coordinates in a separate state. You don’t necessarily need to do this, but depending on if you want to handle your viewport and marker separately you might want to.
In order to generate the map we must use the mapboxAccessToken
prop in ReactMapGl
and set its value to our Mapbox token. The mapStyle
prop determines the look and style of the map, and if you want to use a different map type you can find other values in Mapbox’s documentation on their website.
The onMove
prop handles the panning or dragging of the map. The event that gets automatically passed to us has a viewState
property which we can use to set our viewport
to our new value which will re-center the map to that point.
Inside of ReactMapGl
component we are adding the Marker
component. Both of these are a part of the react-map-gl
package. Marker has props for longitude and latitude which determine its position. We are also setting its draggable
prop to true
, so it can be dragged, and onDragEnd
is the prop which handles what happens when we are done dragging the marker, so we are passing our handleMarkerDrag
function to it. This function updates our marker’s coordinates, and also our address coordinates, which will update the viewport and re-center our map to marker’s new location. If you don’t want for your map to move with the marker you would implement that part a bit differently.
Inside of the Marker
we are adding an img
element which uses our PointerIcon
as its src
. The Marker
handles the marker’s behaviour, and the img
handles the marker’s look.
That’s it as far as our functionalities go. If you want to style the app the same way I did you can use the following code. Make sure to put all of these files in your styles folder, except for the App.scss
. I won’t go through them in detail as we are mainly just setting up some colors, centering things, and adding some additional space in the form of margins and paddings. If you are using vanila CSS for this just change your file extensions to .css
and adjust the selectors accordingly.
// App.scss
@import url("https://fonts.googleapis.com/css2?family=Poppins&display=swap");
body {
margin: 0;
background: linear-gradient(30deg, rgb(0, 39, 65), rgb(0, 91, 151));
color: white;
font-family: "Poppins", sans-serif;
}
#root {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.App {
display: flex;
width: 1000px;
justify-content: center;
}
@media only screen and (max-width: 700px) {
.App {
flex-direction: column;
align-items: center;
}
#root {
justify-content: start;
}
}
// AddressForm.scss
.form {
display: flex;
flex-direction: column;
padding: 2em;
margin-right: 3em;
label {
margin-bottom: 4px;
width: 100%;
text-align: left;
}
input {
width: 250px;
padding: 0.75em;
margin-bottom: 0.5em;
border: 1px solid white;
border-radius: 4px;
font-family: "Poppins", sans-serif;
}
.buttons {
display: flex;
margin-top: 1em;
button {
padding: 1em 2em;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-family: "Poppins", sans-serif;
background-color: rgb(0, 194, 120);
&.confirm-button {
margin-right: 1em;
}
&.reset-button {
background-color: rgb(255, 65, 43);
}
}
}
}
// AutoCompleteInput.scss
.autoCompleteInputContainer {
position: relative;
.addressSuggestions {
position: absolute;
top: 1.75em;
background-color: white;
border: 1px solid black;
overflow: hidden;
border-radius: 4px;
color: black;
z-index: 999;
padding: 0;
li {
list-style: none;
border-bottom: 1px solid black;
padding: 0.5em 1em;
&:hover {
background-color: rgb(22, 66, 128);
color: white;
cursor: pointer;
}
}
}
}
// Map.scss
.map {
width: 400px;
height: 400px;
margin: auto 0;
.marker {
width: 50px;
height: 50px;
}
}
@media only screen and (max-width: 700px) {
.map {
margin-bottom: 2em;
}
}
With that our Address Search app with autocomplete and map generation is finished. You can of course expand it with other interesting functionalities. You may want to add debounce or throttling to the AutoCompleteInput
to prevent fetching the suggestions with each keyboard click. As discussed previously, you should handle the API key and calls in a more secure way if you decide to use this in production. You may also decide to handle the form submit in a better way that suits more to your use-case, instead of just logging the final address.
If you do end up improving this app send me a message. I would love to see your creations!