Fermin Perdomo

Senior Full Stack Engineer | PHP | JavaScript

How to render realtime record with firebase in react the right way

Fermin Perdomo
September 30, 2025

If you’ve ever worked with Firebase Realtime Database, you know how magical it feels to see data update instantly in your app. But here’s the catch: once your database starts growing to thousands or even millions of records, that magic can quickly turn into lag, unnecessary bandwidth costs, and frustrated users.

A common mistake is to subscribe to an entire node and dump everything into state. It works fine when your dataset is small — but at scale, you’ll end up trying to render way more than your UI (or your users) can handle.

The good news? You don’t have to choose between “realtime” and “performance.” With the right approach, you can keep your app fast, efficient, and responsive even as your database grows.

In this post, I’ll walk you through:

  • How to listen to a single record by ID in realtime.
  • How to list many records efficiently with pagination.
  • Why indexes in your Firebase rules are critical for performance.
  • A simple React hook you can reuse across your app.

What we’ll build

  • A tiny Firebase bootstrap (kept out of components)
  • A reusable useRealtimeValue hook (subscribes/unsubscribes safely)
  • A <UserById /> component that renders a single record in realtime
  • A paginated list pattern for many records (with indexing for performance)
  • Rules and indexing to avoid the dreaded “Using an unspecified index” warning

1) Minimal Firebase setup (one-time)

Create firebase.ts:

// firebase.ts
import { initializeApp, getApps } from "firebase/app";
import { getDatabase, connectDatabaseEmulator } from "firebase/database";

const firebaseConfig = {
  apiKey: "YOUR_KEY",
  authDomain: "YOUR_PROJECT.firebaseapp.com",
  databaseURL: "https://YOUR_PROJECT.firebaseio.com",
  projectId: "YOUR_PROJECT_ID",
};

export const app = getApps().length ? getApps()[0] : initializeApp(firebaseConfig);
export const db = getDatabase(app);

// For local dev (optional):
// if (import.meta.env.DEV) connectDatabaseEmulator(db, "127.0.0.1", 9000);

Why: keep initialization outside components to avoid re-inits.

2) A reusable hook for realtime reads

// useRealtimeValue.ts
import { useEffect, useState } from "react";
import { onValue, ref, DatabaseReference } from "firebase/database";
import { db } from "./firebase";

type Options<T> = {
  parse?: (val: unknown) => T | null; // optional sanitizer/mapper
  enabled?: boolean;                  // gate the listener
};

export function useRealtimeValue<T = unknown>(
  path: string | DatabaseReference,
  { parse, enabled = true }: Options<T> = {}
) {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(!!enabled);
  const [error, setError] = useState<Error | null>(null);

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

    const r = typeof path === "string" ? ref(db, path) : path;

    setIsLoading(true);
    const off = onValue(
      r,
      (snap) => {
        const raw = snap.exists() ? snap.val() : null;
        setData(parse ? parse(raw) : (raw as T | null));
        setIsLoading(false);
      },
      (err) => {
        setError(err as Error);
        setIsLoading(false);
      }
    );

    // Clean up to prevent memory leaks / duplicate listeners
    return () => off();
  }, [path, enabled, parse]);

  return { data, isLoading, error };
}

Why: centralize subscription logic (loading state, error, cleanup, mapping) so your components stay simple.

3) Rendering a single record by ID — the right way

// TripById.tsx
import { ref } from "firebase/database";
import { db } from "./firebase";
import { useRealtimeValue } from "./useRealtimeValue";

type Trip = {
  name: string;
  destination: string;
  updatedAt: number; // millis
  // ...other fields
};

export function TripById({ tripId }: { tripId: string }) {
  const tripRef = ref(db, `trips/${tripId}`);

  const { data: trip, isLoading, error } = useRealtimeValue<Trip>(tripRef);

  if (isLoading) return <p>Loading trip…</p>;
  if (error) return <p role="alert">Failed to load: {error.message}</p>;
  if (!trip) return <p>Trip not found.</p>;

  return (
    <article className="rounded-xl p-4 shadow-sm border">
      <h2 className="text-lg font-semibold">{trip.name}</h2>
      <p className="opacity-80">Destination: {trip.destination}</p>
      <small className="opacity-60">
        Updated: {new Date(trip.updatedAt).toLocaleString()}
      </small>
    </article>
  );
}

Key points

  • We subscribe directly to trips/{id} (narrow scope = less data).
  • Hook handles cleanup and errors.
  • Component is pure and tiny.

4) Rendering many records without melting the client

Never onValue(ref(db, "trips")) on a massive node. Instead, page with a sort and a cursor. Realtime Database supports orderByKey or orderByChild + startAt / endAt / limitToFirst / limitToLast.

Data shape (example):

{
  "trips": {
    "trip_001": { "name": "Hike", "destination": "Rockies", "updatedAt": 1727664000000 },
    "trip_002": { "name": "Beach", "destination": "Miami",   "updatedAt": 1727750400000 }
  }
}

Rules indexing (database.rules.json):

{
  "rules": {
    "trips": {
      ".read": true,
      ".write": true,
      ".indexOn": ["updatedAt"]   // <- critical for orderByChild("updatedAt")
    }
  }
}

Paginated window (newest first by updatedAt):

// TripList.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  ref,
  query,
  orderByChild,
  endAt,
  limitToLast,
  onChildAdded,
  onChildChanged,
  onChildRemoved,
  off,
} from "firebase/database";
import { db } from "./firebase";

type Trip = {
  name: string;
  destination: string;
  updatedAt: number;
};

const PAGE_SIZE = 25;

export function TripList() {
  const [items, setItems] = useState<Record<string, Trip>>({});
  const [cursor, setCursor] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const listenerHandle = useRef<ReturnType<typeof ref> | null>(null);

  const attachWindow = useCallback((endAtTs: number | null) => {
    // Remove previous listeners to avoid duplicate events
    if (listenerHandle.current) off(listenerHandle.current);

    setLoading(true);
    const now = Date.now();
    const boundary = endAtTs ?? now;

    const q = query(
      ref(db, "trips"),
      orderByChild("updatedAt"),
      endAt(boundary),
      limitToLast(PAGE_SIZE)
    );

    const add = onChildAdded(q, (snap) => {
      if (!snap.exists()) return;
      setItems((prev) => ({ ...prev, [snap.key!]: snap.val() as Trip }));
    });
    const change = onChildChanged(q, (snap) => {
      if (!snap.exists()) return;
      setItems((prev) => ({ ...prev, [snap.key!]: snap.val() as Trip }));
    });
    const remove = onChildRemoved(q, (snap) => {
      setItems((prev) => {
        const copy = { ...prev };
        delete copy[snap.key!];
        return copy;
      });
    });

    // keep a handle to detach later
    listenerHandle.current = ref(db, "trips");
    setLoading(false);

    // cleanup function for this window
    return () => {
      add(); change(); remove(); // these calls detach their respective listeners
    };
  }, []);

  // Initial window
  useEffect(() => {
    const cleanup = attachWindow(null);
    return () => {
      if (cleanup) cleanup();
      if (listenerHandle.current) off(listenerHandle.current);
    };
  }, [attachWindow]);

  // List newest → oldest
  const list = useMemo(
    () =>
      Object.entries(items)
        .map(([id, t]) => ({ id, ...t }))
        .sort((a, b) => b.updatedAt - a.updatedAt),
    [items]
  );

  const loadOlder = () => {
    if (!list.length) return;
    const oldest = list[list.length - 1].updatedAt;
    setCursor(oldest - 1);
    attachWindow(oldest - 1);
  };

  return (
    <section>
      <h2 className="text-lg font-semibold mb-2">Trips</h2>
      <ul style={{ maxHeight: 480, overflow: "auto" }}>
        {list.map((t) => (
          <li key={t.id} className="py-1">
            <span className="font-medium">{t.name}</span> → {t.destination}{" "}
            <small className="opacity-60">
              {new Date(t.updatedAt).toLocaleString()}
            </small>
          </li>
        ))}
      </ul>

      <button
        className="mt-3 rounded px-3 py-2 border"
        disabled={loading}
        onClick={loadOlder}
      >
        {loading ? "Loading…" : "Load older"}
      </button>
    </section>
  );
}

Why this scales

  • Windowed listening: you only keep listeners for a slice (reduces reads & memory).
  • Indexed query: server filters/sorts; no full-node downloads.
  • Stable cursor: endAt(oldestTs - 1) pages older results reliably.
Tip: For huge feeds, render with react-window to virtualize rows.

5) Avoiding common pitfalls

  • Unspecified index warning
    If you see:
  • FIREBASE WARNING: Using an unspecified index…
     add ".indexOn": ["updatedAt"] (or the field you order by) at the exact node you query.
  • Leaking listeners
    Always return a cleanup function in useEffect and call the detach functions from onChild*.
  • Loading entire nodes
    Never read trips without limitToFirst/Last + an order, unless the node is small.
  • Overweight list items
    Denormalize: store light list data in trip_summaries/{id}, heavy fields in trips/{id}. List from summaries; open detail from full doc.

6) Realtime Database rules (deploy from local)

database.rules.json (example):

{
  "rules": {
    "trips": {
      ".read": "auth != null",
      ".write": "auth != null && auth.uid == newData.child('ownerId').val()",
      ".indexOn": ["updatedAt"]
    },
    "users": {
      ".read": "auth != null",
      ".write": "auth != null",
      ".indexOn": ["updatedAt"]
    }
  }
}

Deploy from your machine:

npm i -g firebase-tools
firebase login
firebase init database   # if not already initialized
firebase deploy --only database

Test safely with Emulator:

firebase emulators:start --only database
# In code: connectDatabaseEmulator(db, "127.0.0.1", 9000)

Final thoughts

Rendering a single record in realtime is easy; doing it right means thinking about data shape, indexes, and lifecycle. Keep your listeners scoped, your queries indexed, and your UI virtualized—and your React + Firebase app will stay fast, even as your data grows.

If you want, share your trips schema and I’ll tailor the hook and list window specifically for your app (including TypeScript types and a virtualized list example)

Reactions

Loading reactions...
Log in to react to this post.

Comments

Please login to leave a comment.

Newsletter