Jurij Tokarski

Jurij Tokarski

Merging Two Firestore Listeners for Cross-Field OR Queries

Firestore can't OR across different field types in a real-time query. Two parallel listeners merged client-side can.

The assignment feature needed a real-time subscription: show tenders where creator_id == userId OR assignee_ids array-contains userId. A cross-field OR.

Firestore's Filter.or() works for same-field conditions. For fundamentally different field types — equality vs. array-contains — composite OR queries don't compose cleanly, and the SDK support varies by version. The alternative is a denormalised collection: fan out writes to a user_tenders subcollection on every state change. That's a write-time tax and more surface area for issues down the line.

The working approach: two separate onSnapshot listeners, results merged client-side into a Map keyed by document ID.

export const firebaseCompanyTendersSubscribeByCreatorOrAssignee = (
  companyId,
  userId,
  callback
) => {
  const merged = new Map();

  const unsubCreator = onSnapshot(
    query(COLLECTION_TENDERS(companyId), where("creator_id", "==", userId)),
    (snap) => {
      snap.docs.forEach((doc) => merged.set(doc.id, { id: doc.id, ...doc.data() }));
      snap.docChanges().forEach(({ type, doc }) => {
        if (type === "removed") merged.delete(doc.id);
      });
      callback(Array.from(merged.values()));
    }
  );

  const unsubAssignee = onSnapshot(
    query(COLLECTION_TENDERS(companyId), where("assignee_ids", "array-contains", userId)),
    (snap) => {
      snap.docs.forEach((doc) => merged.set(doc.id, { id: doc.id, ...doc.data() }));
      snap.docChanges().forEach(({ type, doc }) => {
        if (type === "removed") merged.delete(doc.id);
      });
      callback(Array.from(merged.values()));
    }
  );

  return () => {
    unsubCreator();
    unsubAssignee();
  };
};

Deduplication is automatic — same document ID overwrites itself in the Map. Either listener updating triggers a re-merge and emits a fresh array. The cleanup path requires calling both unsubscribes; returning just one leaks the other listener.

No fan-out collection. No schema changes. The subscription logic stays in one place.

Got thoughts on this post? Reply viaEmail/Twitter/X/LinkedIn

Subscribe to the newsletter:

About Jurij Tokarski

Hey 👋 I'm Jurij. I run Varstatt and create software. Usually, I'm deep in the work shipping for clients or building for myself. Sometimes, I share bits I don't want to forget: mostly about software, products and self-employment.

RSSjurij@varstatt.comx.comlinkedin.com