Go back
Building a proximity feature into maps with React
Feb 25, 2025 · 17 min read
On Google Maps, there’s a feature that I find quite fascinating. It is quite common if you’ve used the app before.
It is that one where you type a business or service name into the search bar with the following keywords, using a gym for example, “Gyms near me” and you’d get a couple of nice recommendations that are close to you based on your current location.
In this article, we’ll walk through the process of doing something similar. But instead of making something quite as robust as what’s on Google Maps, we’ll use predefined data.
This guide will be useful if you happen to be working on a platform or feature at work that likely wants to recommend a couple of things to users.
Say an e-commerce platform that recommends dresses to buy at the closest retail store near your location and you walk in to get, a ride-hailing platform that tells you how close the next rider is to your location, etc.
Even if you’re not doing any of the stuff I mentioned previously you can still follow along. No knowledge is a waste.
Overview
For this feature, we’ll be using React with Typescript, an opensource map library react-leaflet built as a wrapper for Leaflet: a JavaScript library for interactive maps, geolib: a library providing basic geospatial operations, the OpenStreetMap API for reverse-geocoding operations, and SWR for data-fetching and cache-invalidations.
Let’s jump right into it by setting up the project with Vite. Since I mentioned using React with Typescript, we’ll use the react-ts
template from Vite
pnpm create vite app-name --template react-ts
When this setup is done, move into the directory and install the following dependencies
pnpm add react-leaflet leaflet geolib swr
Don’t forget to include the type declarations from leaflet as a devDependency
too.
pnpm add -D @types/leaflet
We need this for proper type-inference in the components we’ll create.
Getting started
To begin this process, let’s create a file called data.ts
in the root directory. In it, I’ll create the type we want the array of people objects to hold.
export type Person = {
id: number;
name: string;
email: string;
location: {
type: string;
coordinates: [number, number];
};
};
Next, in the same file, I’ll add a couple of items to the PEOPLE
array below. For brevity, we’ll keep the data concise;
export const PEOPLE: Person[] = [
{
id: 1,
name: "Adeola Adebayo",
email: "adeola.adebayo@example.com",
location: {
type: "Point",
coordinates: [7.3772, 3.9163],
},
},
{
id: 2,
name: "Chinelo Okafor",
email: "chinelo.okafor@example.com",
location: {
type: "Point",
coordinates: [7.3903, 3.9308],
},
},
{
id: 3,
name: "Emeka Nwosu",
email: "emeka.nwosu@example.com",
location: {
type: "Point",
coordinates: [7.3533, 3.9376],
},
},
{
id: 4,
name: "Funmi Alade",
email: "funmi.alade@example.com",
location: {
type: "Point",
coordinates: [7.4052, 3.9115],
},
},
// rest of the data
]
The data above is based on my current coordinates which I asked ChatGPT to generate for me. We’ll get to how I obtained my geo coordinates later in the article.
Now that we have the data ready, we should render the default map view using components from react-leaflet.
import React from "react";
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
export const CustomMap = () => {
const [mapCenter, setMapCenter] = React.useSate<[number, number]>([51.505, -0.09]);
return (
<MapContainer
center={mapCenter}
zoom={14}
scrollWheelZoom={false}
style={{
height: "100vh",
width: "100vw",
}}
>
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={position}>
<Popup>
A pretty CSS3 popup. <br /> Easily customizable.
</Popup>
</Marker>
</MapContainer>
)
}
The snippet above should render a Map component on your webpage with the center position somewhere in New York City with a MapPin through the <Marker />
component, and a “pretty CSS3 popup”.
But, it wouldn’t do it quite well and you’ll end up with a wonky layout of the Map on your screen. “Why?” You might ask me. Well, this is because we’re missing an important step which is to add the script and CSS files from leaflet.
We can do that by adding the following to the <head />
tag in index.html
.
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
defer
/>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
defer
></script>
Always make sure the script is after the CSS file, and if you’re trying this out in a Next.js project, you should place the CSS reference in the <head />
tag in _document.tsx
.
The script should go into _app.tsx
, using the custom script component from "``next/script``"
. You should have something like this:
import type { AppProps } from "next/app";
import Script from "next/script";
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossOrigin=""
></Script>
<Component {...pageProps} />
</>
);
}
With this, the layout issue of the map should be gone by now.
Creating reusable components for the Map
In the previous section, we rendered a default map view. The next thing to do now is create two Map pins. The first one would represent my current position (location) on the map and the second would represent the number of people within a specific radius close to my current location.
So, if there are five people within my location, we should see five Map pins. To add a cherry on top of this feature. We’ll make my pin draggable such that when I change my position by dragging it to a new point on the map, the number of people within my new position should correspond.
Let’s start with the first one; Ideally, we’d put this in /components/pin.tsx
but feel free to place it anywhere that works for you.
We’ll begin by declaring the component’s interface with the props we want it to accept.
import { LatLngLiteral } from "leaflet";
interface DraggablePinProps {
position: LatLngLiteral;
address?: string;
onPositionChange: (newPosition: LatLngLiteral) => void;
}
From the interface above, we want the component to receive three props: position
, address
, and onPositionChange
to keep the pin’s position, my address — which we’ll get to later on — and a callback function to handle the position of the pin when it is dragged from one point on the map to another.
In the snippet above, you’ll also notice how the position’s type is LatLngLiteral
, a tuple from the leaflet module. It gives us access to the lat
and lng
properties.
We have the props the component should receive. Let’s create the component now.
export const DraggablePin = ({
address,
position,
onPositionChange,
}: DraggablePinProps) => {
const [markerPosition, setMarkerPosition] = React.useState<LatLngLiteral>(position);
return (
<Marker
position={markerPosition}
draggable={true}
>
<Popup>
<strong>You are here!</strong>
<p>{address}</p>
</Popup>
</Marker>
);
};
By setting the draggable
prop value to true
in the snippet above, we make this component that renders a draggable pin on the map. But, we want to do more than just this — drag a pin around.
react-leaflet exposes a few APIs from their library that let us subscribe to events. One of them is a hook called useMapEvents
. We can use it to create an instance for the pin component and subscribe to an event.
In this case, a click event; When this event fires, we set the marker position in state with the current coordinates, and pass the same coordinates to the callback function.
const map = useMapEvents({
click(e) {
setMarkerPosition(e.latlng);
onPositionChange(e.latlng);
},
});
The next thing we need to make sure of now is a way to update the component’s state when we drag the pin around. To accomplish this we can access a dragend
callback handler in the eventHandlers
prop of the <Marker />
component like so:
eventHandlers={{
dragend(e) {
const newPosition = e.target.getLatLng();
setMarkerPosition(newPosition);
onPositionChange(newPosition);
},
}}
From the snippet above, whenever we drag the pin from one point on the map to another, we obtain its new position with e.target.getLatLng()
, update the state, and the position change callback respectively.
With these changes, we now have a functional component. Now let’s render it on the Map.
But… before we go further with that idea, we need to update the current map component so it becomes this:
import React from "react";
import { LatLngLiteral } from "react-leaflet";
import { DraggablePin } from "./pin";
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
export const CustomMap = () => {
const [mapCenter, setMapCenter] = React.useSate<[number, number]>([51.505, -0.09]);
const [currentLocation, setCurrentLocation] = React.useState<LatLngLiteral | null>(null);
React.useEffect(() => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
setCurrentLocation({ lat: latitude, lng: longitude });
setMapCenter([latitude, longitude]);
},
(error) => {
console.log("Error obtaining user's location", error);
},
);
}
}, []);
const handlePinMovement = (position: LatLngLiteral) => {
setCurrentLocation({ lat: position.lat, lng: position.lng });
setMapCenter([position.lat, position.lng]);
};
return (
<MapContainer
center={mapCenter}
zoom={14}
scrollWheelZoom={false}
style={{
height: "100vh",
width: "100vw",
}}
>
// ...previous content
{currentLocation && (
<DraggablePin
onPositionChange={handlePinMovement}
position={currentLocation}
address={loading ? "loading addresss..." : address}
/>
)}
</MapContainer>
)
}
Let’s look at the changes in the component above. First, you’ll notice, that there’s a new state variable currentLocation
, and when the map renders for the first time, we obtain the user’s (my) location with the Geolocation API. You can learn more about how to use it here
When the map is visited for the first time, the browser asks for the user’s permission to access their location. If they accept, we proceed to set the current location and update the map center, if not, an error is logged to the console, and we do nothing.
The same thing pretty much happens in the handlePinMovement
callback. When there’s a change, update both state variables — mapCenter
and currentLocation
.
You must have noticed how the value of address
is rendered conditionally here. Don’t worry, we’ll get to it soon
address={loading ? "loading addresss..." : address}
To the final bit, we render the DraggablePin
if there’s a current location. So if the user rejects the location access, we don’t render any pin on the map.
So far, we’ve worked on creating the draggable pin and proceeded to render it on the map based on the current location if the user consents. By now, you should have a map with a marker/pin on it. Kudos!
We need to create a second map pin to show nearby people. The process is almost similar, just a slight difference. As we did with the first component, let’s specify the props we want this component to receive
interface MapPinProps extends Pick<DraggablePinProps, "position"> {
name: string;
email: string;
}
In the snippet above, you’ll notice the interface declaration is a bit different from what we did with the draggable pin. To explain; This interface inherits the properties from DraggablePinProps
, but instead of taking all of its properties, we use the Pick
utility from Typescript to choose the prop we want.
In this case, it's the position
prop, and in the interface’s body, we include the remaining properties.
Now, here’s what the component should look like:
export const MapPin = ({ position, name, email }: MapPinProps) => {
const { data: address, loading } = useAddress({
lat: Number(position.lat),
lng: Number(position.lng),
});
return (
<Marker position={position}>
<Popup>
<p>{email}</p>
<p>{name}</p>
<p>{loading ? "loading user address..." : address}</p>
</Popup>
</Marker>
);
};
In the snippet above, you’ll notice how we’re using a hook that takes in the position of the pin and uses it to give us an address. The same process works for the draggable pin in the map component we saw earlier.
Calculating distances between two points on the map
This is an important part of this feature, as it is what we’ll use to determine how to render people within a specific radius of my current location.
Let’s create a geo.ts
file inside a utils
folder. We’ll start by creating a function called calculateDistance
to help us with the distance between two points on a map. It uses the getDistance
function from the geolib
module
import { getDistance } from "geolib";
import { LatLngLiteral } from "leaflet";
export type DistanceGetterParams = {
point1: LatLngLiteral;
point2: LatLngLiteral;
};
export const calculateDistance = (params: DistanceGetterParams): number => {
const { point1, point2 } = params;
if (!point1 || !point2) return 0;
return getDistance(
{
lat: point1.lat,
lng: point2.lng,
},
{ lat: point2.lat, lng: point2.lng },
);
};
Next, we create a function that helps us obtain people within a specified radius. The function is pretty basic as we just filter based on the radius supplied in the function argument.
import { Person } from "../data";
export type NearbyPeopleGetterParams = {
people: Person[];
radius: number;
currentLocation: LatLngLiteral;
};
export const findNearbyPeople = (params: NearbyPeopleGetterParams): User[] => {
const { people, radius, currentLocation } = params;
return people.filter((person) => {
const distance = calculateDistance({
point1: {
lat: person.location.coordinates[0],
lng: person.location.coordinates[1],
},
point2: currentLocation,
});
return distance <= radius;
})
};
The next function sends a request to an OpenStreetMap endpoint that helps us reverse geocode coordinates on the map into a human-readable address instead of rendering latitude and longitude values.
export type CoordinatesParams = {
lat: number;
lng: number;
};
export const reverseGeoCoordinates = async (params: CoordinatesParams) => {
const { lat, lng } = params;
if (!lat || !lng)
return "Sorry! Latitude and Longitude parameters are required";
const url = new URL("https://nominatim.openstreetmap.org/reverse");
url.searchParams.append("format", String("json"));
url.searchParams.append("lat", String(lat));
url.searchParams.append("lon", String(lng));
try {
const request = await fetch(url.toString());
const response = await request.json();
return response?.display_name;
} catch (error) {
console.error(error);
}
};
In it, you’ll see how I used the URL()
constructor to structure the URL by appending the necessary query parameters. This function is helpful, as we’ll use it in the address hook you’ve seen in the previous sections.
Using the util functions in custom hooks
Both pin components accept an address
prop and in fact, we need a way to ensure that the addresses are updated instantly whenever the draggable pin’s location changes.
This is where the swr
module enters the scene. With SWR we can create reusable data-fetching hooks without the need to go the conventional route that may be error-prone.
The snippet below shows the useAddress
hook. One thing to observe is the cacheKey
definition, you’ll notice how both lat
and lng
args are supplied. This is so that when the values change anytime in the component’s lifecycle, the data corresponds with the new values.
import useSWR from "swr";
import { reverseGeoCoordinates } from "../utils/geo";
export const useAddress = ({ lat, lng }: { lat: number; lng: number }) => {
const cacheKey = ["current-addr", `${lat}${lng}`];
const { data, error, isLoading } = useSWR(
cacheKey,
() =>
reverseGeoCoordinates({
lat,
lng,
}),
{ revalidateIfStale: true, revalidateOnFocus: false },
);
return {
data,
error,
loading: isLoading,
};
};
You can learn more about these options { revalidateIfStale: true, revalidateOnFocus: false }
here.
Here’s how the hook is used in CustomMap
const { data: address, loading } = useAddress({
lat: Number(currentLocation?.lat),
lng: Number(currentLocation?.lng),
});
Now, that we have an idea of reusable data hooks with SWR, we need to create one that helps us retrieve the list of nearby people. Let’s do that by creating a new file in /hooks
called useNearbyPeople.ts
import { LatLngLiteral } from "leaflet";
import { PEOPLE } from "../data";
import useSWR from "swr";
import { findNearbyPeople } from "../utils/geo";
export const useNearbyPeople = ({
radius = 5000,
currentLocation,
}: {
radius?: number;
currentLocation: LatLngLiteral;
}) => {
const key = ["nearby-people", currentLocation];
const { data, error, isLoading } = useSWR(
key,
() =>
findNearbyPeople({
people: PEOPLE,
radius,
currentLocation,
}),
{ revalidateOnMount: false, revalidateOnFocus: false },
);
return {
nearbyPeople: data,
error,
loading: isLoading,
};
};
Just as we did in useAddress
, this hook gets updated data whenever the current location changes. i.e. when the pin moves. If you look closely, you’ll see that the radius argument has a default value: 5000
and is optional too.
So, if you choose to use the hook without passing a specific radius, it uses the default value.
Here’s the full content of <CustomMap />
below
import { LatLngLiteral } from "leaflet";
import React from "react";
import { MapContainer, TileLayer } from "react-leaflet";
import { DraggablePin, MapPin } from "./pin";
import { Person } from "../data";
import { useAddress } from "../hooks/useAddress";
import { useNearbyPeople } from "../hooks/useNearbyPeople";
export const CustomMap = () => {
const [mapCenter, setMapCenter] = React.useState<[number, number]>([
51.505, -0.09,
]);
const [currentLocation, setCurrentLocation] =
React.useState<LatLngLiteral | null>(null);
const { nearbyPeople } = useNearbyPeople({
currentLocation: currentLocation as LatLngLiteral,
});
React.useEffect(() => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
setMapCenter([latitude, longitude]);
setCurrentLocation({ lat: latitude, lng: longitude });
},
(error) => {
console.error("Failed to obtain user location:", error);
},
);
}
}, []);
const handlePinMovement = (position: LatLngLiteral) => {
setCurrentLocation({ lat: position.lat, lng: position.lng });
setMapCenter([position.lat, position.lng]);
};
const { data: address, loading } = useAddress({
lat: Number(currentLocation?.lat),
lng: Number(currentLocation?.lng),
});
return (
<MapContainer
center={mapCenter}
zoom={10}
scrollWheelZoom={false}
style={{
height: "100vh",
width: "100vw",
}}
>
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{currentLocation && (
<DraggablePin
address={loading ? "loading address..." : address}
position={currentLocation}
onPositionChange={handlePinMovement}
/>
)}
{nearbyPeople &&
nearbyPeople?.map((person: Person) => {
return (
<MapPin
key={person.id}
email={person.email}
name={person.name}
position={{
lat: person.location.coordinates[0],
lng: person.location.coordinates[1],
}}
/>
);
})}
</MapContainer>
);
};
Wrapping up & UX Improvements
By now, when you open the map, you should have more than one marker on it, awesome work! But, now, I bet you can’t recognize which pin is draggable, because they’re all blue (the same color), and you’re left with this question: “Can I change a marker’s color?”
Fortunately, yes, you can! We can use the leaflet module to fix this. Here’s how:
import * as L from "leaflet";
const redIcon = new L.Icon({
iconUrl:
"https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png",
shadowUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
You can explore other colors here.
One other thing to point out is a way to ensure that the map goes to the current location when the user allows their location to be read.
So, ideally, when the pin component renders we want to immediately “fly” to that current location;
React.useEffect(() => {
setMarkerPosition(position);
map.flyTo(position, map.getZoom());
}, [map, position]);
By default, this interaction would happen every time the position of the marker changes. Now, the draggable pin component looks like this:
import * as L from "leaflet";
import { LatLngLiteral } from "leaflet";
import React from "react";
import { Marker, Popup, useMapEvents } from "react-leaflet";
import { useAddress } from "../hooks/useAddress";
interface DraggablePinProps {
position: LatLngLiteral;
address?: string;
onPositionChange: (newPosition: LatLngLiteral) => void;
}
export const DraggablePin = ({
address,
position,
onPositionChange,
}: DraggablePinProps) => {
const [markerPosition, setMarkerPosition] = React.useState<LatLngLiteral>(position);
const redIcon = new L.Icon({
iconUrl:
"https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png",
shadowUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
const map = useMapEvents({
click(e) {
setMarkerPosition(e.latlng);
onPositionChange(e.latlng);
},
});
React.useEffect(() => {
setMarkerPosition(position);
map.flyTo(position, map.getZoom());
}, [map, position]);
return (
<Marker
icon={redIcon}
position={markerPosition}
draggable={true}
eventHandlers={{
dragend(e) {
const newPosition = e.target.getLatLng();
setMarkerPosition(newPosition);
onPositionChange(newPosition);
},
}}
>
<Popup>
<strong>You are here!</strong>
<p>{address}</p>
</Popup>
</Marker>
);
};
interface MapPinProps extends Pick<DraggablePinProps, "position"> {
name: string;
email: string;
}
export const MapPin = ({ position, name, email }: MapPinProps) => {
const { data: address, loading } = useAddress({
lat: Number(position.lat),
lng: Number(position.lng),
});
return (
<Marker position={position}>
<Popup>
<p>{email}</p>
<p>{name}</p>
<p>{loading ? "loading user address..." : address}</p>
</Popup>
</Marker>
);
};
There are other ways to calculate the distance between two points on a sphere using their latitudes and longitudes and a very popular one is the Haversine formula.
Here’s more on it, if you want to go into the details of obtaining basic geospatial data like the distance between two points. It is a nice way to learn about the underlying principles that could potentially run libraries like geolib.
You can also find the code here