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.
You will also get notified when someone joins or leaves the room and the current number of connected users.
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.
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.
You can try out this app from here. 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.
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:
{
"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.
{
"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.
// 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
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.
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
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.
// 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 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.
// 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.
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.
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.
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.
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
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
- user share location: emit create room event
- server receive the event
- generate room id
- join the room
- attach room id to current socket client
- emit room created event and roomid
- 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.
// 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.
- User emit βjoinRoomβ with roomId
- Check if room exist:
- exist
- join room
- attach roomid to socket
- get the room creator and notify him
- message the room joiner(socket)
- !exist
- notify the socket
- exist
...
...
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
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.
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.
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:
- For Room Creator:
- If the leaving user is the creator:
- Destroy the Room:
- Notify Other Users
- If the leaving user is the creator:
- For Room Joiner:
- If the leaving user is a participant:
- Notify the Creator:
- Inform the room creator about the departure.
- Leave the Room:
- Ensure the leaving user is removed from the room's participant list.
- Notify the Creator:
- If the leaving user is a participant:
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
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
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.
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.
Top comments (5)
Kudos for using Leaflet rather than Google Maps!
(And websockets.)
Don't mind if I steal your projectπ
@backslant there are already apps out there for Android which allows you to share your location. Check out on play store.
If you want to build something like this project, I don't think you would need anything extra. You will just have to build the UI for Android and the server code will be the same.
Nice article
Thanks