Ứng dụng chat thời gian thực với Firebase
Ứng dụng chat nhiều phòng chạy hoàn toàn phía client, dùng Firebase Realtime Database làm kênh thời gian thực và đăng nhập bằng Google.
- React 18
- Firebase
- Tailwind CSS
- Vite
Chat Chit là ứng dụng nhắn tin theo phòng, viết bằng React 18 và Vite. Toàn bộ ứng dụng chạy trong trình duyệt, không có server riêng: dữ liệu và auth đều đi qua Firebase.
Vì sao là Realtime Database#
Bài toán chỉ có một yêu cầu thật sự: tin nhắn của người này phải xuất hiện trên máy người kia ngay lập tức. Tôi có cân nhắc dựng một server WebSocket riêng, nhưng làm vậy nghĩa là tôi phải tự viết thêm ba thứ mà cuộc trò chuyện không đòi hỏi sự sáng tạo nào: lưu lịch sử, phát lại cho người mở sau, và auth người dùng. Realtime Database gói cả ba vào một SDK — mỗi lần ghi vào một đường dẫn là mọi client đang lắng nghe đường dẫn đó nhận được ngay, còn dữ liệu vẫn nằm lại để lần mở sau đọc được.
Tôi chọn Realtime Database chứ không phải Firestore vì hình dạng dữ liệu ở đây đúng là một cây JSON: phòng chứa tin nhắn, và không có query nào phức tạp hơn "đọc cả nhánh này". Đổi lại, ứng dụng không cần backend nào cả — chỉ còn một bundle tĩnh.
import { initializeApp } from 'firebase/app';
import { getDatabase } from 'firebase/database';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
/* Toàn bộ config đọc từ biến môi trường của Vite; .env nằm ngoài git. */
const app = initializeApp({
apiKey: import.meta.env.VITE_API_KEY,
authDomain: import.meta.env.VITE_AUTH_DOMAIN,
databaseURL: import.meta.env.VITE_DATABASE_URL,
projectId: import.meta.env.VITE_PROJECT_ID,
});
export const db = getDatabase(app);
export const auth = getAuth(app);
export const provider = new GoogleAuthProvider();Mô hình dữ liệu và phạm vi listener#
Cây dữ liệu cố tình để nông: conversations/{roomId} giữ metadata của phòng, còn tin nhắn
nằm ở conversations/{roomId}/messages/{pushId} với đúng bốn trường — tên, avatar, nội
dung, thời điểm.
Điểm tôi cân nhắc nhiều nhất là phạm vi của listener. Cách dễ nhất là lắng nghe nguyên
nhánh conversations rồi lọc ở client, nhưng như vậy mỗi tin nhắn của một phòng bất kỳ sẽ
kéo lại toàn bộ dữ liệu về máy. Thay vào đó có hai listener tách rời: một cho danh sách
phòng, một gắn vào đúng phòng đang mở và được huỷ mỗi khi đổi phòng.
useEffect(() => {
if (!selectedRoom) return;
/* Chỉ mở dòng dữ liệu của đúng phòng đang xem, huỷ khi đổi phòng. */
const messagesRef = ref(db, `conversations/${selectedRoom.id}/messages`);
const unsubscribe = onValue(messagesRef, (snapshot) => {
const data = snapshot.val();
setMessages(data ? Object.keys(data).map((key) => ({ id: key, ...data[key] })) : []);
});
return () => unsubscribe();
}, [selectedRoom]);
const handleSendMessage = () => {
if (!message.trim() || !selectedRoom) return;
/* push() sinh khoá tăng dần theo thời gian ngay tại client: gửi xong là xong. */
push(ref(db, `conversations/${selectedRoom.id}/messages`), {
name: user.displayName,
avatar: user.avatar,
content: message,
date: Date.now(),
});
setMessage('');
};Việc dùng push() có một hệ quả dễ chịu: key do client sinh ra và tăng dần theo thời
gian, nên không cần chờ server cấp ID, và thứ tự key trong snapshot đã chính là thứ tự
hiển thị. Không có bước sắp xếp nào ở giao diện.
Vào phòng: đọc một lần trước khi mở stream#
Vào phòng không dùng listener mà dùng một lệnh get() duy nhất. Lý do là để phân biệt
được hai trạng thái mà một subscription sẽ trộn lẫn: "mã phòng không tồn tại" — phải báo
lỗi ngay, và "phòng có thật nhưng chưa ai nhắn" — vẫn phải mở. Mã phòng chính là
Date.now() lúc tạo, đủ để chia sẻ qua tin nhắn mà không cần thêm dịch vụ sinh mã.
Các phòng đã vào được giữ thành tab ở phần trên màn hình, nên đọc song song nhiều cuộc trò chuyện không phải rời khỏi trang. Danh sách tab nằm trong state của React, nghĩa là tải lại trang sẽ mất tab và phải nhập lại mã — đánh đổi tôi chấp nhận để không phải ghi thêm quan hệ người dùng–phòng vào database.
Đăng nhập Google và ranh giới session#
Đăng nhập dùng signInWithPopup, còn onAuthStateChanged là nguồn chân lý: mỗi lần trạng
thái đổi, email và avatar được đồng bộ xuống localStorage. Route bảo vệ đọc thẳng
localStorage thay vì chờ Firebase khởi tạo — nhờ vậy điều hướng diễn ra đồng bộ, không
có khoảnh khắc nháy sang màn hình đăng nhập rồi quay lại.
Kết quả#
- Chat thời gian thực cho nhiều phòng mà không cần một dòng code server nào
- Listener bám đúng phòng đang mở, không kéo dữ liệu thừa về máy
- Security boundary được ghi nhận rõ ràng thay vì để mặc định là đã an toàn