Real-time data synchronization with Firestore listeners for live updates.
Overview
ShipSafe supports real-time data synchronization using Firestore listeners. This allows your application to automatically update when data changes in Firestore, providing a seamless, live user experience without manual polling or refresh.
Key Features:
- Real-time updates - Automatically sync when data changes
- Client-side listeners - Use Firestore client SDK
- Type-safe - TypeScript support with converters
- Automatic cleanup - Unsubscribe when component unmounts
- Efficient - Only listens to needed data
Architecture
Client-Side Only
Real-time listeners use the Firebase client SDK (not Admin SDK):
- Requires client component - Must use
"use client" - Respects security rules - Subject to Firestore security rules
- Live updates - Automatic updates when data changes
- Efficient - Only sends changes, not full documents
Location: src/lib/firebase/client.ts
Basic Usage
Single Document Listener
"use client";
import { useEffect, useState } from "react";
import { getFirestoreInstance } from "@/lib/firebase/client";
import { doc, onSnapshot } from "firebase/firestore";
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const firestore = getFirestoreInstance();
const userRef = doc(firestore, "users", userId);
// Subscribe to document changes
const unsubscribe = onSnapshot(
userRef,
(snapshot) => {
if (snapshot.exists()) {
setUser(snapshot.data());
} else {
setUser(null);
}
setLoading(false);
},
(error) => {
console.error("Error listening to user:", error);
setLoading(false);
}
);
// Cleanup: unsubscribe when component unmounts
return () => unsubscribe();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return <div>{user.displayName}</div>;
}
Collection Listener
"use client";
import { useEffect, useState } from "react";
import { getFirestoreInstance } from "@/lib/firebase/client";
import { collection, onSnapshot, query, where } from "firebase/firestore";
function ActiveSubscriptions({ userId }: { userId: string }) {
const [subscriptions, setSubscriptions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const firestore = getFirestoreInstance();
const subscriptionsRef = collection(firestore, "subscriptions");
// Query with filter
const q = query(
subscriptionsRef,
where("userId", "==", userId),
where("status", "==", "ACTIVE")
);
// Subscribe to query changes
const unsubscribe = onSnapshot(
q,
(snapshot) => {
const data = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
setSubscriptions(data);
setLoading(false);
},
(error) => {
console.error("Error listening to subscriptions:", error);
setLoading(false);
}
);
// Cleanup
return () => unsubscribe();
}, [userId]);
if (loading) return <div>Loading...</div>;
return (
<ul>
{subscriptions.map((sub) => (
<li key={sub.id}>{sub.subscriptionId}</li>
))}
</ul>
);
}
Type-Safe Listeners
Using Model Converters
"use client";
import { useEffect, useState } from "react";
import { getFirestoreInstance } from "@/lib/firebase/client";
import { doc, onSnapshot } from "firebase/firestore";
import { userFromFirestore, type User } from "@/models/user";
function TypedUserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const firestore = getFirestoreInstance();
const userRef = doc(firestore, "users", userId);
const unsubscribe = onSnapshot(
userRef,
(snapshot) => {
if (snapshot.exists()) {
// Use converter for type safety
const userData = userFromFirestore({
...snapshot.data(),
uid: snapshot.id,
});
setUser(userData);
} else {
setUser(null);
}
setLoading(false);
},
(error) => {
console.error("Error:", error);
setLoading(false);
}
);
return () => unsubscribe();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return <div>{user.displayName}</div>;
}
Custom Hooks
Reusable Subscription Hook
// src/hooks/useSubscription.ts
"use client";
import { useEffect, useState } from "react";
import { getFirestoreInstance } from "@/lib/firebase/client";
import { doc, onSnapshot } from "firebase/firestore";
import {
subscriptionFromFirestore,
type Subscription,
} from "@/models/subscription";
export function useSubscription(subscriptionId: string | null) {
const [subscription, setSubscription] = useState<Subscription | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!subscriptionId) {
setSubscription(null);
setLoading(false);
return;
}
const firestore = getFirestoreInstance();
const subRef = doc(firestore, "subscriptions", subscriptionId);
const unsubscribe = onSnapshot(
subRef,
(snapshot) => {
if (snapshot.exists()) {
const subData = subscriptionFromFirestore({
...snapshot.data(),
subscriptionId: snapshot.id,
});
setSubscription(subData);
} else {
setSubscription(null);
}
setLoading(false);
setError(null);
},
(err) => {
console.error("Error listening to subscription:", err);
setError(err as Error);
setLoading(false);
}
);
return () => unsubscribe();
}, [subscriptionId]);
return { subscription, loading, error };
}
// Usage
function SubscriptionStatus({ subscriptionId }: { subscriptionId: string }) {
const { subscription, loading, error } = useSubscription(subscriptionId);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!subscription) return <div>No subscription found</div>;
return <div>Status: {subscription.status}</div>;
}
Generic Document Hook
// src/hooks/useFirestoreDocument.ts
"use client";
import { useEffect, useState } from "react";
import { getFirestoreInstance } from "@/lib/firebase/client";
import { doc, onSnapshot, DocumentSnapshot } from "firebase/firestore";
export function useFirestoreDocument<T>(
collection: string,
documentId: string | null,
converter?: (snapshot: DocumentSnapshot) => T
) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!documentId) {
setData(null);
setLoading(false);
return;
}
const firestore = getFirestoreInstance();
const docRef = doc(firestore, collection, documentId);
const unsubscribe = onSnapshot(
docRef,
(snapshot) => {
if (snapshot.exists()) {
const converted = converter
? converter(snapshot)
: ({ id: snapshot.id, ...snapshot.data() } as T);
setData(converted);
} else {
setData(null);
}
setLoading(false);
setError(null);
},
(err) => {
console.error(`Error listening to ${collection}/${documentId}:`, err);
setError(err as Error);
setLoading(false);
}
);
return () => unsubscribe();
}, [collection, documentId, converter]);
return { data, loading, error };
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useFirestoreDocument(
"users",
userId,
(snapshot) =>
userFromFirestore({
...snapshot.data()!,
uid: snapshot.id,
})
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return <div>User not found</div>;
return <div>{user.displayName}</div>;
}
Advanced Patterns
Multiple Listeners
"use client";
import { useEffect, useState } from "react";
import { getFirestoreInstance } from "@/lib/firebase/client";
import { doc, onSnapshot } from "firebase/firestore";
function UserDashboard({ userId }: { userId: string }) {
const [user, setUser] = useState<any>(null);
const [subscription, setSubscription] = useState<any>(null);
useEffect(() => {
const firestore = getFirestoreInstance();
const unsubscribes: (() => void)[] = [];
// Listen to user
const userUnsubscribe = onSnapshot(
doc(firestore, "users", userId),
(snapshot) => {
setUser(snapshot.data());
}
);
unsubscribes.push(userUnsubscribe);
// Listen to subscription (if exists)
const subscriptionUnsubscribe = onSnapshot(
doc(firestore, "subscriptions", userId),
(snapshot) => {
setSubscription(snapshot.exists() ? snapshot.data() : null);
}
);
unsubscribes.push(subscriptionUnsubscribe);
// Cleanup all listeners
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
}, [userId]);
return (
<div>
<div>User: {user?.displayName}</div>
<div>Subscription: {subscription?.status || "None"}</div>
</div>
);
}
Conditional Listening
"use client";
import { useEffect, useState } from "react";
import { getFirestoreInstance } from "@/lib/firebase/client";
import { doc, onSnapshot } from "firebase/firestore";
function ConditionalListener({ userId, enabled }: { userId: string; enabled: boolean }) {
const [user, setUser] = useState<any>(null);
useEffect(() => {
if (!enabled) {
setUser(null);
return;
}
const firestore = getFirestoreInstance();
const unsubscribe = onSnapshot(doc(firestore, "users", userId), (snapshot) => {
setUser(snapshot.data());
});
return () => unsubscribe();
}, [userId, enabled]);
if (!enabled) return <div>Listening disabled</div>;
return <div>{user?.displayName}</div>;
}
Best Practices
- Always unsubscribe - Clean up listeners on component unmount
- Handle errors - Provide error callback in
onSnapshot - Show loading states - Display loading indicator while fetching
- Use converters - Convert Firestore data to TypeScript types
- Minimize listeners - Only listen to data you need
- Optimize queries - Use indexes for filtered queries
- Handle null/empty - Check if document/query exists before using
Security Considerations
Firestore Security Rules
Real-time listeners respect Firestore security rules. Ensure your rules allow read access:
// firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Users can read their own user document
match /users/{userId} {
allow read: if request.auth != null && request.auth.uid == userId;
}
// Users can read their own subscriptions
match /subscriptions/{subscriptionId} {
allow read: if request.auth != null &&
resource.data.userId == request.auth.uid;
}
}
}
Server-Side Alternative
For sensitive data, use server-side polling instead:
// Server component with polling
async function ServerSubscriptionStatus({ userId }: { userId: string }) {
const subscription = await getUserSubscriptionFromFirestore(userId);
return <div>Status: {subscription?.status}</div>;
}
Troubleshooting
Listener Not Updating
- Check security rules - Ensure rules allow read access
- Verify document path - Ensure collection/document IDs are correct
- Check network - Verify internet connection
- Review console - Check for error messages
Performance Issues
- Limit listeners - Don't create too many listeners
- Use indexes - Create composite indexes for queries
- Filter queries - Use
whereclauses to limit data - Pagination - Use
limit()for large collections
Learn More
- Database Features - Firestore operations
- Real-time Listeners Tutorial - Detailed guide
- Firestore Security Rules