Most tutorials teach you how to add a database to a React app. Almost none teach you how to replace one. That’s a shame because the second skill is where the design gets interesting and it’s the one that quietly separates code that ages well from code that calcifies.
I learned this the cheap way recently. Halfway through building a subscription tracker, I decided to move it off Cloud Firestore and onto Firebase Realtime Database. Two different databases and two different APIs. I braced for a long afternoon of find-and-replace across the whole codebase.
It took about twenty minutes and I looked at four files. None of them were components.
Photo by charlesdeluvio on Unsplash
The thing nobody tells you about Firestore vs. Realtime Database
On paper they are like siblings. Both live in Firebase, both sync in real time and both are “NoSQL.” In practice they disagree about almost everything that matters when you write code.
- Reads. Firestore gives you onSnapshot over a query. Realtime Database gives you onValue over a ref.
- Writes. Firestore has addDoc / updateDoc / deleteDoc. Realtime Database has push / update / remove.
- Shape. Firestore returns an array of documents. Realtime Database returns one big nested object keyed by push-IDs, and you have to walk it with snapshot.forEach.
- Time. This is the sneaky one. Firestore has a first-class Timestamp type with a .toDate() method. Realtime Database has no such thing and its serverTimestamp() resolves to a plain number of milliseconds since 1970.
That last difference is exactly the kind of detail that turns a simple swap into a bug hunt. If you had been storing JavaScript Date objects and calling .toDate() on the way out, half your UI breaks silently the moment you switch.
The seam
Somewhere early on, I had written a file called subscriptions.js whose entire job was to be the only place in the app that knew the database existed. Everything else such as every component, every screen talked to it through four functions below.
watchSubscriptions(uid, onData, onError) // returns an unsubscribe fn
addSubscription(uid, data)
updateSubscription(uid, id, data)
deleteSubscription(uid, id)
That is the seam. A thin boundary between what my app wants (give me this user’s subscriptions, and tell me when they change) and how that happens (Firestore? Realtime Database? a REST API? localStorage?).
People with a backend background will recognise this as a cousin of the repository pattern or the data-access layer in hexagonal / ports-and-adapter_s_ architecture. But you do not need the vocabulary to get the benefit. You just need the discipline to never import firebase/firestore inside a component.
The migration was almost entirely inside those four functions.
// before — Firestore
return onSnapshot(query(col, orderBy('createdAt','desc')), (snap) => {
onData(snap.docs.map((d) => ({ id: d.id, ...d.data() })));
});
// after — Realtime Database
return onValue(query(ref, orderByChild('createdAt')), (snap) => {
const items = [];
snap.forEach((c) => items.push({ id: c.key, ...c.val() }));
onData(items.reverse()); // RTDB orders ascending; flip for newest-first
});
Same signature in and same shape out, [{ id, ...fields }], newest first. The component consuming it is a React context with a live list and a loading flag that had no idea anything had changed. It still received an array. It still rerendered. The real time magic still worked.
The one leak — and why leaks are fine if you plan for them
No abstraction is perfect and the Timestamp problem was the seam's one genuine leak. Realtime Database simply does not store a Date. I had to change the write side to store epoch milliseconds.
renewalDate: form.renewalDate ? new Date(form.renewalDate).getTime() : null
On the read side I had a tiny helper from day one.
function toDate(value) {
if (!value) return null;
if (typeof value.toDate === 'function') return value.toDate(); // Firestore Timestamp
const d = new Date(value); // ISO string OR epoch ms
return Number.isNaN(d.getTime()) ? null : d;
}
I had written that else branch months earlier without much thought, just to be defensive about the different shapes a date might arrive in. It meant my entire formatting and days until renewal logic already handled raw numbers. The read side did not need a single edit. Defensive normalisation at the boundary had paid for itself.
The takeaway
The seam is nott a framework or a library. It is a single rule applied early, when it feels like over-engineering.
The database gets exactly one doorway into your app. Everything passes through it, in your own vocabulary, in your own data shapes.
It costs you almost nothing up front but one extra file and four functions. The payoff is asymmetric and arrives later usually on the day you are told to “just switch the backend real quick.” On that day, the difference between a twenty-minute change and a two-day rewrite is whether you drew the seam back when it was boring to do so.

Top comments (0)