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='&copy; <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='&copy; <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