DEV Community

Building a Real-time Chat Application with Google Cloud Firestore and React

In this tutorial, we'll create a real-time chat application using Google Cloud Firestore and React. Firestore offers powerful real-time synchronization capabilities, making it perfect for chat applications, collaborative tools, and live dashboards.

Why Google Cloud Firestore?

While DynamoDB and CosmosDB are popular choices, Firestore offers unique advantages:

  • Real-time listeners with automatic data synchronization
  • Powerful querying capabilities
  • Offline data persistence
  • Automatic scaling
  • Simple SDK integration
  • Free tier for development and small applications

Prerequisites

  • Node.js and npm installed
  • Google Cloud account
  • Basic knowledge of React and JavaScript
  • Familiarity with async/await

Project Setup

First, create a new React project and install dependencies:

npx create-react-app chat-app
cd chat-app
npm install firebase @mui/material @mui/icons-material @emotion/react @emotion/styled date-fns
Enter fullscreen mode Exit fullscreen mode

Firebase Configuration

  1. Create a new project in the Firebase Console
  2. Enable Firestore in your project
  3. Create src/firebase.js:
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
import { getAuth } from 'firebase/auth';

const firebaseConfig = {
  apiKey: "your-api-key",
  authDomain: "your-project.firebaseapp.com",
  projectId: "your-project-id",
  storageBucket: "your-project.appspot.com",
  messagingSenderId: "your-sender-id",
  appId: "your-app-id"
};

const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
export const auth = getAuth(app);
Enter fullscreen mode Exit fullscreen mode

Database Schema

Create the following collections in Firestore:

rooms/
  ├── roomId/
     ├── name: string
     ├── description: string
     └── createdAt: timestamp
  
messages/
  ├── messageId/
     ├── roomId: string
     ├── userId: string
     ├── text: string
     ├── createdAt: timestamp
     └── userName: string
Enter fullscreen mode Exit fullscreen mode

Components Implementation

Create the chat components:

ChatRoom Component (src/components/ChatRoom.js):

import React, { useEffect, useState, useRef } from 'react';
import { collection, query, orderBy, limit, onSnapshot, addDoc, serverTimestamp } from 'firebase/firestore';
import { db, auth } from '../firebase';
import { Box, TextField, Button, Paper, Typography, List, ListItem, ListItemText } from '@mui/material';
import { formatDistance } from 'date-fns';

const ChatRoom = ({ roomId }) => {
  const [messages, setMessages] = useState([]);
  const [newMessage, setNewMessage] = useState('');
  const messagesEndRef = useRef(null);

  useEffect(() => {
    // Query messages for this room
    const q = query(
      collection(db, 'messages'),
      orderBy('createdAt', 'asc'),
      limit(100)
    );

    // Subscribe to real-time updates
    const unsubscribe = onSnapshot(q, (snapshot) => {
      const newMessages = [];
      snapshot.forEach((doc) => {
        newMessages.push({ id: doc.id, ...doc.data() });
      });
      setMessages(newMessages);
      scrollToBottom();
    });

    return () => unsubscribe();
  }, [roomId]);

  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!newMessage.trim()) return;

    try {
      await addDoc(collection(db, 'messages'), {
        text: newMessage,
        createdAt: serverTimestamp(),
        userId: auth.currentUser.uid,
        userName: auth.currentUser.displayName || 'Anonymous',
        roomId
      });

      setNewMessage('');
    } catch (error) {
      console.error('Error sending message:', error);
    }
  };

  return (
    <Box sx={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
      <Paper elevation={3} sx={{ flex: 1, overflow: 'auto', p: 2, mb: 2 }}>
        <List>
          {messages.map((message) => (
            <ListItem
              key={message.id}
              sx={{
                display: 'flex',
                flexDirection: 'column',
                alignItems: message.userId === auth.currentUser?.uid ? 'flex-end' : 'flex-start'
              }}
            >
              <Typography variant="caption" color="textSecondary">
                {message.userName}  {
                  message.createdAt && formatDistance(message.createdAt.toDate(), new Date(), { addSuffix: true })
                }
              </Typography>
              <Paper
                elevation={1}
                sx={{
                  p: 1,
                  bgcolor: message.userId === auth.currentUser?.uid ? 'primary.light' : 'grey.100',
                  maxWidth: '70%'
                }}
              >
                <Typography>{message.text}</Typography>
              </Paper>
            </ListItem>
          ))}
          <div ref={messagesEndRef} />
        </List>
      </Paper>

      <Box component="form" onSubmit={handleSubmit} sx={{ p: 2 }}>
        <TextField
          fullWidth
          value={newMessage}
          onChange={(e) => setNewMessage(e.target.value)}
          placeholder="Type a message..."
          variant="outlined"
          sx={{ mr: 1 }}
          InputProps={{
            endAdornment: (
              <Button type="submit" variant="contained" disabled={!newMessage.trim()}>
                Send
              </Button>
            )
          }}
        />
      </Box>
    </Box>
  );
};

export default ChatRoom;
Enter fullscreen mode Exit fullscreen mode

Room List Component (src/components/RoomList.js):

import React, { useEffect, useState } from 'react';
import { collection, query, orderBy, onSnapshot, addDoc, serverTimestamp } from 'firebase/firestore';
import { db } from '../firebase';
import { List, ListItem, ListItemText, Button, Dialog, DialogTitle, 
         DialogContent, TextField, DialogActions } from '@mui/material';

const RoomList = ({ onRoomSelect }) => {
  const [rooms, setRooms] = useState([]);
  const [open, setOpen] = useState(false);
  const [newRoomName, setNewRoomName] = useState('');
  const [newRoomDescription, setNewRoomDescription] = useState('');

  useEffect(() => {
    const q = query(collection(db, 'rooms'), orderBy('createdAt', 'desc'));

    const unsubscribe = onSnapshot(q, (snapshot) => {
      const newRooms = [];
      snapshot.forEach((doc) => {
        newRooms.push({ id: doc.id, ...doc.data() });
      });
      setRooms(newRooms);
    });

    return () => unsubscribe();
  }, []);

  const handleCreateRoom = async () => {
    if (!newRoomName.trim()) return;

    try {
      await addDoc(collection(db, 'rooms'), {
        name: newRoomName,
        description: newRoomDescription,
        createdAt: serverTimestamp()
      });

      setOpen(false);
      setNewRoomName('');
      setNewRoomDescription('');
    } catch (error) {
      console.error('Error creating room:', error);
    }
  };

  return (
    <>
      <List>
        {rooms.map((room) => (
          <ListItem
            key={room.id}
            button
            onClick={() => onRoomSelect(room.id)}
          >
            <ListItemText 
              primary={room.name}
              secondary={room.description}
            />
          </ListItem>
        ))}
      </List>

      <Button variant="contained" onClick={() => setOpen(true)}>
        Create New Room
      </Button>

      <Dialog open={open} onClose={() => setOpen(false)}>
        <DialogTitle>Create New Chat Room</DialogTitle>
        <DialogContent>
          <TextField
            autoFocus
            margin="dense"
            label="Room Name"
            fullWidth
            value={newRoomName}
            onChange={(e) => setNewRoomName(e.target.value)}
          />
          <TextField
            margin="dense"
            label="Description"
            fullWidth
            multiline
            rows={2}
            value={newRoomDescription}
            onChange={(e) => setNewRoomDescription(e.target.value)}
          />
        </DialogContent>
        <DialogActions>
          <Button onClick={() => setOpen(false)}>Cancel</Button>
          <Button onClick={handleCreateRoom} variant="contained">
            Create
          </Button>
        </DialogActions>
      </Dialog>
    </>
  );
};

export default RoomList;
Enter fullscreen mode Exit fullscreen mode

Main App Component (src/App.js):

import React, { useState } from 'react';
import { Container, Grid, Paper } from '@mui/material';
import ChatRoom from './components/ChatRoom';
import RoomList from './components/RoomList';

function App() {
  const [selectedRoom, setSelectedRoom] = useState(null);

  return (
    <Container maxWidth="lg" sx={{ height: '100vh', py: 2 }}>
      <Grid container spacing={2} sx={{ height: '100%' }}>
        <Grid item xs={3}>
          <Paper sx={{ height: '100%', p: 2 }}>
            <RoomList onRoomSelect={setSelectedRoom} />
          </Paper>
        </Grid>
        <Grid item xs={9}>
          {selectedRoom ? (
            <ChatRoom roomId={selectedRoom} />
          ) : (
            <Paper sx={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
              Select a room to start chatting
            </Paper>
          )}
        </Grid>
      </Grid>
    </Container>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Security Rules

Set up Firestore security rules:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /messages/{messageId} {
      allow read: if true;
      allow create: if request.auth != null;
      allow update, delete: if request.auth != null && 
                            request.auth.uid == resource.data.userId;
    }

    match /rooms/{roomId} {
      allow read: if true;
      allow create: if request.auth != null;
      allow update, delete: if request.auth != null;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Running the Application

  1. Set up environment variables:
cp .env.example .env
# Add your Firebase configuration
Enter fullscreen mode Exit fullscreen mode
  1. Start the development server:
npm start
Enter fullscreen mode Exit fullscreen mode

Key Features

  1. Real-time message synchronization
  2. Room creation and management
  3. Message history persistence
  4. User authentication integration
  5. Responsive Material-UI design
  6. Automatic message timestamps
  7. Scroll-to-bottom functionality

Performance Considerations

When working with Firestore:

  1. Query Optimization

    • Use compound queries for better performance
    • Implement pagination for large datasets
    • Cache frequently accessed data
  2. Real-time Listeners

    • Detach listeners when components unmount
    • Limit the number of concurrent listeners
    • Use appropriate query limits
  3. Offline Support

    • Enable offline persistence for better user experience
    • Handle offline/online state transitions
    • Implement retry logic for failed operations
  4. Cost Optimization

    • Monitor read/write operations
    • Use batch operations when possible
    • Implement appropriate caching strategies

Deployment

  1. Build the application:
npm run build
Enter fullscreen mode Exit fullscreen mode
  1. Deploy to Firebase Hosting:
npm install -g firebase-tools
firebase login
firebase init
firebase deploy
Enter fullscreen mode Exit fullscreen mode

Conclusion

This implementation showcases how to build a real-time chat application using Google Cloud Firestore. The combination of Firestore's real-time capabilities and React's component model makes it easy to create responsive, scalable applications.

Future enhancements could include:

  • File attachments
  • User presence indicators
  • Message reactions
  • Rich text formatting
  • Voice messages

The complete source code is available on GitHub: [Link to your repository]

Resources

Top comments (0)