Complete guide to implementing real-time data synchronization with Firestore listeners in ShipSafe.

Overview

Real-time listeners allow your application to automatically update when data changes in Firestore, providing a seamless, live user experience without manual polling or refresh.

What You'll Learn:

  • Setting up Firestore listeners
  • Listening to single documents
  • Listening to collections
  • Handling connection states
  • Cleanup and performance optimization

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

Important: Real-time listeners are client-side only. For server-side operations, use Firestore queries via Admin SDK.

Basic Usage

Single Document Listener

Listen to changes to a single document:

"use client";

import { useEffect, useState } from "react";
import { getFirestoreInstance } from "@/lib/firebase/client";
import { doc, onSnapshot } from "firebase/firestore";
import { userFromFirestore } from "@/models/user";

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<any>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!userId) return;

    const firestore = getFirestoreInstance();
    const userRef = doc(firestore, "users", userId);

    // Subscribe to document changes
    const unsubscribe = onSnapshot(
      userRef,
      (snapshot) => {
        if (snapshot.exists()) {
          // Document exists - convert and set user
          const userData = userFromFirestore({
            ...snapshot.data(),
            uid: snapshot.id,
          } as any);
          setUser(userData);
        } else {
          // Document doesn't exist
          setUser(null);
        }
        setLoading(false);
        setError(null);
      },
      (error) => {
        console.error("Error listening to user:", error);
        setError("Failed to load user");
        setLoading(false);
      }
    );

    // Cleanup: unsubscribe when component unmounts or userId changes
    return () => unsubscribe();
  }, [userId]);

  if (loading) {
    return (
      <div className="flex items-center justify-center p-8">
        <span className="loading loading-spinner loading-lg"></span>
      </div>
    );
  }

  if (error) {
    return (
      <div className="alert alert-error">
        <span>{error}</span>
      </div>
    );
  }

  if (!user) {
    return <div>User not found</div>;
  }

  return (
    <div>
      <h1>{user.displayName || user.email}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Key Points:

  • ✅ Use onSnapshot() to subscribe
  • ✅ Return cleanup function from useEffect
  • ✅ Handle loading, error, and empty states
  • ✅ Use converters for type safety

Collection Listener

Listen to changes to a collection query:

"use client";

import { useEffect, useState } from "react";
import { getFirestoreInstance } from "@/lib/firebase/client";
import { collection, onSnapshot, query, where, orderBy } from "firebase/firestore";
import { subscriptionFromFirestore } from "@/models/subscription";

function ActiveSubscriptions({ userId }: { userId: string }) {
  const [subscriptions, setSubscriptions] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (!userId) return;

    const firestore = getFirestoreInstance();
    const subscriptionsRef = collection(firestore, "subscriptions");

    // Build query
    const q = query(
      subscriptionsRef,
      where("userId", "==", userId),
      where("status", "==", "active"),
      orderBy("createdAt", "desc")
    );

    // Subscribe to query changes
    const unsubscribe = onSnapshot(
      q,
      (snapshot) => {
        const items = snapshot.docs.map((doc) =>
          subscriptionFromFirestore({
            ...doc.data(),
            subscriptionId: doc.id,
          } as any)
        );
        setSubscriptions(items);
        setLoading(false);
      },
      (error) => {
        console.error("Error listening to subscriptions:", error);
        setLoading(false);
      }
    );

    // Cleanup
    return () => unsubscribe();
  }, [userId]);

  if (loading) {
    return <div>Loading subscriptions...</div>;
  }

  return (
    <div>
      <h2>Active Subscriptions ({subscriptions.length})</h2>
      {subscriptions.map((sub) => (
        <div key={sub.subscriptionId}>
          <p>Status: {sub.status}</p>
          <p>Plan: {sub.priceId}</p>
        </div>
      ))}
    </div>
  );
}

Advanced Patterns

Custom Hook for Reusability

Create a reusable hook for common listeners:

// 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
): {
  subscription: Subscription | null;
  loading: boolean;
  error: string | null;
} {
  const [subscription, setSubscription] = useState<Subscription | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | 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,
          } as any);
          setSubscription(subData);
          setError(null);
        } else {
          setSubscription(null);
        }
        setLoading(false);
      },
      (err) => {
        console.error("Error listening to subscription:", err);
        setError("Failed to load subscription");
        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 className="alert alert-error">{error}</div>;
  if (!subscription) return <div>No subscription found</div>;

  return <div>Status: {subscription.status}</div>;
}

Multiple Listeners

Listen to multiple documents simultaneously:

"use client";

import { useEffect, useState } from "react";
import { getFirestoreInstance } from "@/lib/firebase/client";
import { doc, onSnapshot } from "firebase/firestore";

function MultiUserView({ userIds }: { userIds: string[] }) {
  const [users, setUsers] = useState<Record<string, any>>({});
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (userIds.length === 0) {
      setUsers({});
      setLoading(false);
      return;
    }

    const firestore = getFirestoreInstance();
    const unsubscribes: (() => void)[] = [];

    // Create listener for each user ID
    userIds.forEach((userId) => {
      const userRef = doc(firestore, "users", userId);

      const unsubscribe = onSnapshot(userRef, (snapshot) => {
        setUsers((prev) => ({
          ...prev,
          [userId]: snapshot.exists() ? snapshot.data() : null,
        }));
        setLoading(false);
      });

      unsubscribes.push(unsubscribe);
    });

    // Cleanup all listeners
    return () => {
      unsubscribes.forEach((unsub) => unsub());
    };
  }, [userIds]);

  return (
    <div>
      {Object.entries(users).map(([userId, user]) => (
        <div key={userId}>
          {user ? user.displayName : "User not found"}
        </div>
      ))}
    </div>
  );
}

Connection States

Listen to Connection State

Firestore provides connection state monitoring:

"use client";

import { useEffect, useState } from "react";
import { getFirestoreInstance } from "@/lib/firebase/client";
import { onSnapshot } from "firebase/firestore";

function ConnectionStatus() {
  const [isOnline, setIsOnline] = useState(true);

  useEffect(() => {
    const firestore = getFirestoreInstance();

    // Listen to connection state
    const unsubscribe = onSnapshot(
      firestore.collection("_").limit(0), // Dummy query for connection state
      () => setIsOnline(true), // Connected
      () => setIsOnline(false) // Disconnected
    );

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

  return (
    <div className={`badge ${isOnline ? "badge-success" : "badge-error"}`}>
      {isOnline ? "Online" : "Offline"}
    </div>
  );
}

Best Practices

1. Always Cleanup Listeners

Do:

useEffect(() => {
  const unsubscribe = onSnapshot(docRef, callback);
  return () => unsubscribe(); // Cleanup
}, [dependencies]);

Don't:

useEffect(() => {
  onSnapshot(docRef, callback);
  // Missing cleanup - memory leak!
}, [dependencies]);

2. Handle Loading and Error States

Do:

const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
  const unsubscribe = onSnapshot(
    ref,
    (snapshot) => {
      setData(snapshot.data());
      setLoading(false);
      setError(null);
    },
    (err) => {
      setError(err.message);
      setLoading(false);
    }
  );
  return () => unsubscribe();
}, []);

3. Use Converters for Type Safety

Do:

const user = userFromFirestore({
  ...snapshot.data(),
  uid: snapshot.id,
});

Don't:

const user = snapshot.data(); // No type safety

4. Optimize Queries

Do:

// Limit results
const q = query(
  collection(firestore, "items"),
  where("status", "==", "active"),
  limit(10)
);

Don't:

// Listening to entire collection
const q = collection(firestore, "items"); // May be expensive

5. Check Document Exists

Do:

onSnapshot(docRef, (snapshot) => {
  if (snapshot.exists()) {
    const data = snapshot.data();
    // Use data
  } else {
    // Handle document doesn't exist
  }
});

Performance Optimization

Limit Listener Scope

Only listen to data you actually need:

// ✅ Good: Query with filters
const q = query(
  collection(firestore, "items"),
  where("userId", "==", userId),
  where("status", "==", "active"),
  limit(10)
);

// ❌ Bad: Listen to entire collection
const q = collection(firestore, "items");

Use Single Document When Possible

// ✅ Good: Single document listener
const docRef = doc(firestore, "users", userId);
onSnapshot(docRef, callback);

// ❌ Bad: Collection query for single document
const q = query(
  collection(firestore, "users"),
  where("__name__", "==", userId)
);

Debounce Rapid Updates

For frequently changing data, consider debouncing:

import { useMemo } from "react";
import { debounce } from "lodash";

function useDebouncedListener(docRef: any, delay: number = 300) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const debouncedSetData = debounce((snapshot: any) => {
      setData(snapshot.data());
    }, delay);

    const unsubscribe = onSnapshot(docRef, debouncedSetData);
    return () => {
      unsubscribe();
      debouncedSetData.cancel();
    };
  }, [docRef, delay]);

  return data;
}

Example: Subscription Status Component

Complete example of a subscription status component with real-time updates:

"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";
import Badge from "@/components/ui/Badge";

interface SubscriptionStatusProps {
  subscriptionId: string | null;
  userId: string;
}

export default function SubscriptionStatus({
  subscriptionId,
  userId,
}: SubscriptionStatusProps) {
  const [subscription, setSubscription] = useState<Subscription | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | 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,
          } as any);

          // Verify subscription belongs to user
          if (subData.userId === userId) {
            setSubscription(subData);
            setError(null);
          } else {
            setError("Subscription not found");
          }
        } else {
          setSubscription(null);
          setError("Subscription not found");
        }
        setLoading(false);
      },
      (err) => {
        console.error("Error listening to subscription:", err);
        setError("Failed to load subscription");
        setLoading(false);
      }
    );

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

  if (loading) {
    return (
      <div className="flex items-center gap-2">
        <span className="loading loading-spinner loading-sm"></span>
        <span>Loading subscription...</span>
      </div>
    );
  }

  if (error) {
    return (
      <div className="alert alert-error">
        <span>{error}</span>
      </div>
    );
  }

  if (!subscription) {
    return (
      <div>
        <Badge variant="warning">No active subscription</Badge>
      </div>
    );
  }

  const getStatusVariant = (status: string) => {
    switch (status) {
      case "active":
        return "success";
      case "canceled":
      case "past_due":
        return "error";
      default:
        return "warning";
    }
  };

  return (
    <div className="space-y-2">
      <div className="flex items-center gap-2">
        <span className="font-semibold">Status:</span>
        <Badge variant={getStatusVariant(subscription.status)}>
          {subscription.status}
        </Badge>
      </div>

      {subscription.cancelAtPeriodEnd && (
        <div className="text-warning">
          Subscription will cancel at end of period
        </div>
      )}

      <div className="text-sm text-base-content/70">
        Next billing: {new Date(subscription.currentPeriodEnd * 1000).toLocaleDateString()}
      </div>
    </div>
  );
}

Troubleshooting

Listener Not Updating

  • ✅ Check if listener is properly subscribed
  • ✅ Verify document exists in Firestore
  • ✅ Check Firestore security rules
  • ✅ Verify converter functions work correctly

Memory Leaks

  • ✅ Always return cleanup function from useEffect
  • ✅ Unsubscribe when component unmounts
  • ✅ Clear listeners when dependencies change

Performance Issues

  • ✅ Limit query results with .limit()
  • ✅ Use filters to reduce scope
  • ✅ Consider pagination for large collections
  • ✅ Debounce rapid updates if needed

Learn More