DEV Community

César Fabián CHÁVEZ LINARES
César Fabián CHÁVEZ LINARES

Posted on

1

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

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read full post →

Top comments (0)

Image of Docusign

🛠️ Bring your solution into Docusign. Reach over 1.6M customers.

Docusign is now extensible. Overcome challenges with disconnected products and inaccessible data by bringing your solutions into Docusign and publishing to 1.6M customers in the App Center.

Learn more