Building a Real-Time Location Sharing App with React, Socket.io, and Leaflet
Tags: react, node, javascript, webdev
Published: 2023-10-02 13:28:25

There are a lot of tutorials on building Chat App with react and socket io. While building a Chat App is a cool way to explore real-time applications, I wanted to be a bit more creative. So, I came up with the idea of creating a location sharing app. Plus, I needed a project for my empty resume that may compensate for my lack of experience.

So, here we go.

LocShare: a real time location sharing app that allows you to share your location to multiple users. 

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tp0pw3kf9p2wxuaqp9hw.png)

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0r009tdz5kq6sntwwc6d.png)

You will also get notified when someone joins or leaves the room and the current number of connected users.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b37noielkxk3ywnxe8kz.png)

As soon as you stop sharing the location, the room will get destroyed, and the connected users won’t be able to see your location any more.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/99cljrn8rde77zupc077.png)

---

This is a location sharing app, which means in order to test this application you have to walk/run/drive miles. Hopefully not. Because browsers provides a way to manipulate your coordinates without you having to leave your couch.

Navigate to the more tools and find the sensor option. From there, you can manipulate your current coordinates.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7vxuegq1eughzjui5tp9.png)

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/aki9z3vol14u6m9n32nq.png)

**You can try out this app from [here](https://locshare.netlify.app/). The backend is hosted on render’s free tier, so it will take some time for the first connection and sometime it's even not available for few hours.**

---
_If you're coding along, you might find it a bit challenging. My emphasis is on the crucial bits of code, the logical flow, and the structure of this app. I encourage you to visit the GitHub repository for the [complete code](https://github.com/manish-mehra/locshare)._

## Setting up the project

In the frontend, I've configured my React app with Vite, TypeScript, and Tailwind CSS. To map our coordinates on a map, we'll use a well-known open-source library named Leaflet.

I've recently begun using TypeScript and applied it in this project. However, it's important to note that TypeScript is optional if you're coding along.

Here are the dependencies of frontend:

```json
{
  "name": "client",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },
  "dependencies": {
    "@types/leaflet": "^1.9.6",
    "@types/react-leaflet": "^3.0.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-icons": "^4.11.0",
    "react-leaflet": "^4.2.1",
    "react-router-dom": "^6.16.0",
    "react-toastify": "^9.1.3",
    "socket.io-client": "^4.7.2"
  },
  "devDependencies": {
    "@types/react": "^18.2.15",
    "@types/react-dom": "^18.2.7",
    "@typescript-eslint/eslint-plugin": "^6.0.0",
    "@typescript-eslint/parser": "^6.0.0",
    "@vitejs/plugin-react": "^4.0.3",
    "autoprefixer": "^10.4.15",
    "eslint": "^8.45.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.3",
    "postcss": "^8.4.29",
    "tailwindcss": "^3.3.3",
    "typescript": "^5.0.2",
    "vite": "^4.4.5"
  }
}
```
For the backend, we have a basic node-express server with the following dependencies.

```json
{
  "name": "server",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "tsc",
    "start": "node dist/index.js",
    "dev": "concurrently \"npx tsc --watch\" \"nodemon -q dist/index.js\""
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "cors": "^2.8.5",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "socket.io": "^4.7.2"
  },
  "devDependencies": {
    "@types/express": "^4.17.17",
    "concurrently": "^8.2.0",
    "nodemon": "^3.0.1",
    "typescript": "^5.2.2"
  }
}
```

## Folder Structure

React App:
```
📁 src
├── 📁 components
│   ├── 📁 Element
│   │   ├── 📄 Header
│   │   ├── 📄 Map
│   │   ├── 📄 Status
│   │   ├── 📄 StatusPanel
│   ├── 📁 Layout
├── 📁 context
│   ├── 📄 socket.tsx
├── 📁 pages
│   ├── 📄 Home.tsx
│   ├── 📄 Location.tsx
├── 📁 types
├── 📄 App.tsx
├── 📄 main.tsx
├── 📄 index.css
...rest
```
Node server:

```
📁 src
├── 📄 index.ts
```

## Create Socket Context

By storing socket in context, we can make it globally available to rest of our app. The connectSocket function handles the connection. If socket is null(no existing connection), it establishes a new socket connection. If socket is not null(disconnected), it connects to socket.

```typescript
// SocketProvider.js
import {useState, createContext, useContext, JSX} from 'react'
import {io, Socket} from 'socket.io-client'
import { SOCKET_URL } from '../config'

type SocketContextType = {
  socket: Socket | null;
  connectSocket: () => void;
}

type SocketProviderProps = {
  children: JSX.Element
}

export const SocketContext = createContext<SocketContextType | null>(null)

export const SocketProvider = ({children}: SocketProviderProps) => {
  const [socket, setSocket] = useState<Socket | null>(null)

  const connectSocket = () => {
    if(!socket) {
      const newSocket: Socket = io(SOCKET_URL)
      setSocket(newSocket)
      return
    }
    socket.connect()
  }

  return (
    <SocketContext.Provider value={{socket, connectSocket}}>
      {children}
    </SocketContext.Provider>
  )
}

export const useSocket = () => {
  const context = useContext(SocketContext)
  if(!context) {
    throw new Error('Something went wrong!')
  }
  return context
}

```
Here, I've made things easier by adding a custom hook called useSocket, which handles the logic of accessing SocketContext along with SocketProvider. This approach simplifies the code, making it easier to read and understand when working with context values in your components.

## Wrapping app with socket context

```typescript
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { SocketProvider } from './context/socket.tsx'
import {ToastContainer} from 'react-toastify'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <SocketProvider>
      <>
        <App />
        <ToastContainer newestOnTop/>
      </>
    </SocketProvider>
  </React.StrictMode>,
)
```

## App Routes

Here, we have our two pages: home and location. From homepage, we can create room and channel our location. The Location page is displayed when the URL matches the pattern /location/1242, indicating that 1242 is a specific room ID or parameter.

These pages are wrapped in a layout component. 

```typescript
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Home from './pages/Home'
import Location from './pages/Location'

function App() {

  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<Home />} />
          <Route path="location/:roomId" element={<Location />}/>
        </Route>
      </Routes>
    </BrowserRouter>
  )
}

export default App
```
Layout Component
```typescript
import { Outlet } from 'react-router-dom'
import Header from '../Elements/Header'

function index() {
  return (
    <div className='flex justify-center px-3 py-2'>
      <div className='flex flex-col w-full md:min-w-full xl:min-w-[1100px] xl:max-w-[1200px] mb-4'>
        <Header />
        <main>
          <Outlet />
        </main>
      </div>
    </div>
  )
}

export default index
```

## Access User Location

To get the current location of the user, we can use the geolocation interface provided by the browser to obtain the current position. This will open a popup, asking user the location permission.

```javascript
// get the current position
navigator.geolocation.getCurrentPosition(success, error, options)
```
success, error(optional), options(optional) are the callback function that are being passed, and through them, we can access the the coordinates or error or pass additional parameters. You can check out the [mozilla doc](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/getCurrentPosition) for more.

We don't just need the current position; we also want to track the user's movement from point A to B

We can have a setInterval function that  repeatedly calls getCurrentPosition(). That’s not required here, because geolocation interface provide an another method called watchPosition(). And this method is called each time the position changes.

```javascript
// watch the current position
let id = navigator.geolocation.watchPosition(success, error, options)

// clear the resource
navigator.geolocation.clearWatch(id)
```
This watchPosition method is like setTimeout or setInterval, a part of browser features, that runs asynchronously in the background and monitor the user’s current position. By storing it in a variable, we are keeping a reference. So later when we no longer want to track the user location, we will clear the variable or set it null. If we don’t do that, it will keep running in the background and that can lead to memory issues and unnecessary resource consumption.

```typescript
type GeolocationPosition = {
  lat: number
  lng: number
}
type LocationStatus = 'accessed' | 'denied' | 'unknown' | 'error'

export default function Home() {
  const [locationStatus, setLocationStatus] = useState<LocationStatus>('unknown')
  const [position, setPosition] = useState<GeolocationPosition | null>(null)

  useEffect(() => {
    let watchId: number | null = null
		// check for geolocation support in browser
    if('geolocation' in navigator) {
      watchId = navigator.geolocation.watchPosition((position) => {
      setPosition({
        lat: position.coords.latitude,
        lng: position.coords.longitude
      })
      setLocationStatus('accessed')
      }, (error) => {
        switch (error.code) {
          case error.PERMISSION_DENIED:
            setLocationStatus('denied')
            break
          case error.POSITION_UNAVAILABLE:
            setLocationStatus('unknown')
            break
          case error.TIMEOUT:
            setLocationStatus('error')
            break
          default:
            setLocationStatus('error')
            break
        }
      })
      return () => {
        if(watchId) {
          navigator.geolocation.clearWatch(watchId)
        }
      }
    }
  }, [])
...
...
```
When the page is loaded, the location prompt appears and ask for location permission. Based on that it handles whether user have allowed location permission or not. Once location is allowed, we have access to the position object that contains the coordinates of user. 

With the error code provided by geolocation, we can nicely handle the error and know exactly why user can’t access location.

## Locate coordinates on map

Once we have the user’s coordinates, we can pass them into our Map component and the location will shown in the map.

We want the map marker to automatically move to the passed coordinates. In our Location Marker component, we put the map.flyTo method, provided by the leaflet instance, inside the useEffect hook with position as the dependency. Whenever, location change, the marker will fly there.

```typescript
import { useState, useEffect } from 'react'
import { MapContainer, TileLayer, useMapEvents, Marker, Popup } from 'react-leaflet'
import { GeolocationPosition } from '../../../types' 
import 'leaflet/dist/leaflet.css'

function Map({ location }: { location: GeolocationPosition }) {
  if (!location) return 'No location found'

  return (
    <div className='w-full bg-gray-100 h-[600px] md:h-[550px]'>
      <MapContainer center={[location.lat, location.lng]} zoom={30} scrollWheelZoom={true} className='h-screen'>
        <TileLayer
          url='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' // OpenStreetMap tile layer URL
        />
        <LocationMarker location={location} />
      </MapContainer>
    </div>
  )
}

function LocationMarker({ location }: { location: GeolocationPosition }) {

  const map = useMapEvents({})  // Use map events to access the Leaflet map instance
  const [position, setPosition] = useState({
    lat: location.lat,
    lng: location.lng
  })

  // Effect to update marker position and fly to the new location when location data changes
  useEffect(() => {
    setPosition({
      lat: location.lat,
      lng: location.lng
    })
    map.flyTo([location.lat, location.lng]) // Fly to the new location on the map
  }, [location])

  return position === null ? null : (
    <Marker position={position}>
      <Popup>User is here!</Popup>
    </Marker>
  )
}

export default Map
```
Now, we can use this component in our homepage.

```typescript
export default function Home() {
  const [locationStatus, setLocationStatus] = useState<LocationStatus>('unknown')
  const [position, setPosition] = useState<GeolocationPosition | null>(null)
  
	// ... rest

return(
<>	
{/** ...rest **/}
				{
	        position && (<Map location={position}/>)
        }
</>
)
```

## Setup node-express socket server

In this code, we've set up a simple HTTP server using Node.js and Express. Then, we've created a Socket.io server instance (io) and passed our Express server to it. Even though, HTTP and Web Sockets are different communication protocols, socket io enables both the HTTP server and the WebSocket server to share the same server instance, allowing them to communicate over the same network port.

```typescript
import express, { Express, Request, Response } from 'express'
import {Socket, Server} from 'socket.io'
import cors from 'cors'
import dotenv from 'dotenv'

dotenv.config()

const app: Express = express()
const port = process.env.PORT || 5000

app.use(cors())
app.use(express.json())

app.get('/', (req: Request, res: Response) => {
  res.send('Welcome to LocShare!')
})


const server = app.listen(port, () => {
  console.log(`Server is running`)
})

const io: Server = new Server(server, {
  cors: {
    origin: '*',
  },
})

io.on('connection', (socket: Socket) => {
  console.log(`User connected: ${socket.id}`)
})
```

When a user share location, we are essentially going to create the room. 

_A *room* is an arbitrary channel that sockets can `join` and `leave`. It can be used to broadcast events to a subset of clients. [read more](https://socket.io/docs/v4/rooms/)_

This room will have a unique id. Once the room is created, we’ll send the roomId in response to the user.

Here’s the flow

1. user share location: emit create room event
2. server receive the event
    1. generate room id
    2. join the room
    3. attach room id to current socket client
    4. emit room created event and roomid
    5. store room creator

💡I’m extending my Socket and attaching an additional property to the socket called  roomId. Later we’ll use this when user left the room.

```typescript
// Define a custom interface extending the Socket interface
interface CustomSocket extends Socket {
  roomId?: string
}
const roomCreator = new Map<string, string>() // roomid => socketid

io.on('connection', (socket: CustomSocket) => {
  console.log(`User connected: ${socket.id}`)

  socket.on('createRoom', (data) => {
    const roomId = Math.random().toString(36).substring(2, 7)
    socket.join(roomId) // joining room in sockets
		socket.roomId = roomId //  attach roomId to socket
    const totalRoomUsers = io.sockets.adapter.rooms.get(roomId)
    socket.emit('roomCreated', { 
      roomId,
      position: data.position,
      totalConnectedUsers: Array.from(totalRoomUsers || []),
    })
    roomCreator.set(roomId, socket.id) // map roomid with socket
  })
})
```

**Joining room**

In order to join the room, our client will emit an event ‘joinRoom’ with the roomId.

1. User emit ‘joinRoom’ with roomId
2. Check if room exist:
    - exist
        1. join room
        2. attach roomid to socket
        3. get the room creator and notify him
        4. message the room joiner(socket)
    - !exist
        1. notify the socket

```typescript
...
...
socket.on('joinRoom', (data: {roomId: string}) => {

    // check if room exists
    const roomExists = io.sockets.adapter.rooms.has(data.roomId)
    if (roomExists) {
      socket.join(data.roomId)
      socket.roomId = data.roomId //  attach roomId to socket

      // Notify the room creator about the new user     
      const creatorSocketID = roomCreator.get(data.roomId)
      if (creatorSocketID) {
        const creatorSocket = io.sockets.sockets.get(creatorSocketID) // get socket instance of creator
        if (creatorSocket) {
          const totalRoomUsers = io.sockets.adapter.rooms.get(data.roomId)
          creatorSocket.emit('userJoinedRoom', {
            userId: socket.id,
            totalConnectedUsers: Array.from(totalRoomUsers || [])
          })
        }
      }
      // msg to joiner
      io.to(`${socket.id}`).emit('roomJoined', {
        status: 'OK',
      })

    } else {
      io.to(`${socket.id}`).emit('roomJoined', {
        status: 'ERROR'
      })
    }
  })
```
For location update

```typescript
socket.on('updateLocation', (data) => {
    io.emit('updateLocationResponse', data)
})
```

## Home page: Connect Sockets & Create Room

Now from the home page, we can connect to the socket server. To share location, user must share his location first. Once we have the coordinates, we can connect to the server and when the user have successfully established the connection, we’ll be automatically emit the createRoom event.

I chose this approach to prevent automatic connection to the server when a user visits the page. In our application, there might be additional pages like a login or register page in the future. To avoid unnecessary connections, I've designed it this way to ensure that we only establish a connection when it's explicitly needed.

```typescript
type SocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
type RoomInfo = {
  roomId: string
  position: GeolocationPosition
  totalConnectedUsers: string[]
}

export default function Home() {
	// ...location related states

  const {socket, connectSocket} = useSocket()
  const [socketStatus, setSocketStatus] = useState<SocketStatus>('disconnected')
	const [roomLink, setRoomLink] = useState<string>('')
	const [roomInfo, setRoomInfo] = useState<RoomInfo | null>(null)

  function connectToSocketServer() {
    connectSocket()
    setSocketStatus('connecting')
  }

  useEffect(() => {
    let watchId: number | null = null
    // ...geolocation logic
  }, [])

  useEffect(() => {
    if(socket) {
      socket.on('connect', () => {
        setSocketStatus('connected')
        socket.emit('createRoom', {
          position
        })
      })

      socket.on('roomCreated', (data: RoomInfo) => {
        toast.success('You are live!', {
          autoClose: 2000,
          })
        setRoomInfo(data)
      })

      socket.on('userJoinedRoom', (data: {userId: string, totalConnectedUsers: string[]}) => {
        setRoomInfo((prev) => {
          if(prev) {
            return {
              ...prev,
              totalConnectedUsers: data.totalConnectedUsers
            }
          }
          return null
        })

        toast.info(`${data.userId} joined the room`, {
          autoClose: 2000,
        })

        position && socket.emit('updateLocation', {
          position
        })
      })

      socket.on('disconnect', () => {
        setSocketStatus('disconnected')
      })
    }
  }, [socket])

  useEffect(() => {
    if(socket) {
      socket.emit('updateLocation', {
        position
      })
    }
  }, [position])


  return (
    <>
			{/* ...rest */}
            {
              socketStatus === 'disconnected' && (
                <div className='flex flex-col gap-6 items-start w-full'>
                  <button 
                    className={`${locationStatus === 'accessed' ? 'bg-purple-800' : 'bg-gray-600 cursor-not-allowed'}`}
                    onClick={() => {
                      if(locationStatus === 'accessed') {
                        connectToSocketServer()
                      } else {
                        toast.error('Please allow location access', {
                          autoClose: 2000,
                        })
                      }
                    }}
                    disabled={locationStatus !== 'accessed'}
                    >Share Location</button>
                  {/*  ...rest */}
              </div>
              )
```

## Location page: access roomId and join room

When the page load, we extract the room ID from the URL. Subsequently, we establish a connection with the socket. Once the connection is successfully established, we trigger the 'join room' event to indicate that the user has joined the specified room.

```typescript
import React, {useState, useEffect} from 'react'
import { useParams } from 'react-router-dom'
import {useSocket} from '../context/socket'

type RoomStatus = 'unknown' | 'joined' | 'not-exist'

function Location() {
  const { roomId } = useParams()
  const { socket, connectSocket } = useSocket()
  const [socketStatus, setSocketStatus] = useState<SocketStatus>('disconnected')
  const [roomStatus, setRoomStatus] = useState<RoomStatus>('unknown')
  const [position, setPosition] = useState<GeolocationPosition | null>(null)

  useEffect(() => {
    connectSocket()
    setSocketStatus('connecting')
    return () => {
      if(socket) {
        socket.disconnect()
        setSocketStatus('disconnected')
      }
    }
  }, [])

  useEffect(() => {

    if(socket){      
      
      socket.on('connect', () => {
        setSocketStatus('connected')
        socket.emit('joinRoom', {
          roomId
        })
      })

      socket.on('roomJoined', ({status}: {status: string}) => {
        if(status === 'OK') {
          setRoomStatus('joined')
        } else if (status === 'ERROR') {
          setRoomStatus('not-exist')
        } else {
          setRoomStatus('unknown')
        }
      })

      socket.on('updateLocationResponse', ({position}:{position: GeolocationPosition}) => {
        if(position) {
          setPosition(position)
        }
      })

      socket.on('disconnect', () => {
        setSocketStatus('disconnected')
      })
    }

  }, [socket])

// ...rest
```

## Server: leave room

Here, the logic when user leaves the room:

User Leaves the Room:

1. For Room Creator:
    - If the leaving user is the creator:
        1. Destroy the Room:
        2. Notify Other Users
2. For Room Joiner:
    - If the leaving user is a participant:
        1. Notify the Creator:
            - Inform the room creator about the departure.
        2. Leave the Room:
            - Ensure the leaving user is removed from the room's participant list.

```typescript
io.on('connection', (socket: CustomSocket) => {
  console.log(`User connected: ${socket.id}`)

// ...rest code

socket.on('disconnect', () => {
    console.log(`User disconnected: ${socket.id}`)

    const roomId = socket.roomId
    if(roomId){
      // if disconnected user is creator, destroy room
      if(roomCreator.get(roomId) === socket.id){
        // notify users in room that room is destroyed
        const roomUsers = io.sockets.adapter.rooms.get(roomId)
        if(roomUsers){
          for (const socketId of roomUsers) {
            io.to(`${socketId}`).emit('roomDestroyed', {
              status: 'OK'
            })
          }
        }
        io.sockets.adapter.rooms.delete(roomId)
        roomCreator.delete(roomId)    
      } else{
        socket.leave(roomId)
        // notify creator that user left room
        const creatorSocketId = roomCreator.get(roomId)
        if(creatorSocketId){
          const creatorSocket = io.sockets.sockets.get(creatorSocketId)
          if(creatorSocket){
            creatorSocket.emit('userLeftRoom', {
              userId: socket.id,
              totalConnectedUsers: Array.from(io.sockets.adapter.rooms.get(roomId) || [])
            })
          }
        }
      }
    }

  })

})
```

## Update home and location page: leave room

home.tsx
```typescript
export default function Home() {

	// ...rest

  useEffect(() => {
    if(socket) {
      socket.on('connect', () => {
        setSocketStatus('connected')
        socket.emit('createRoom', {
          position
        })
      })
			// ...rest

			socket.on('userLeftRoom', (data: {userId: string, totalConnectedUsers: string[]}) => {
        setRoomInfo((prev) => {
          if(prev) {
            return {
                ...prev,
                totalConnectedUsers: data.totalConnectedUsers
              }
            }
            return null
          })
          toast.info(`${data.userId} left the room`, {
            autoClose: 2000,
          })
      })

      socket.on('disconnect', () => {
        setSocketStatus('disconnected')
      })
    }
  }, [socket])
// ...rest
```
location.tsx

```typescript


function Location() {

  // ...rest

  useEffect(() => {

    if(socket){      
      
      socket.on('connect', () => {
        setSocketStatus('connected')
        socket.emit('joinRoom', {
          roomId
        })
      })

      // ...rest

			socket.on('roomDestroyed', () => {
        setRoomStatus('not-exist')
        socket.disconnect()
      })

      socket.on('disconnect', () => {
        setSocketStatus('disconnected')
      })
    }

  }, [socket])

function stopSharingLocation() {
    if(socket){
      socket.disconnect()
      setSocketStatus('disconnected')
      setRoomInfo(null)
      toast.success('You are no longer live!', {
        autoClose: 2000,
      })
    }
  }

// ...rest
```

## We're done!👏🏽

We've covered the fundamental flow and logic of the location sharing application, focusing on the essential aspects. While I haven't provided the JSX and UI implementation code here, you can find the complete source code in the GitHub [repository](https://github.com/manish-mehra/locshare).

---

## Final Words

In this project, I’ve touched the barebones. In the real world, we will have something more robust, with added authentication, database and more. Most importantly the scalability of the app, the amount of users it can handle.

Hopefully, if I get enough time, I would continue this series and try building those features. 

You are welcomed to make a pull request, raise an issue or suggest changes.