---
title: Merging Two Firestore Listeners for Cross-Field OR Queries
url: https://varstatt.com/jurij/p/merging-two-firestore-listeners-for-cross-field-or-queries
author: Jurij Tokarski
date: 2026-03-10
description: Firestore can't OR across different field types in a real-time query. Two parallel listeners merged client-side can.
section: Blog (https://varstatt.com/jurij/archive)
tags: firebase (https://varstatt.com/jurij/c/firebase)
---

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](/production/firebase-audit) `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.

```javascript
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.
