Інтеграція Firebase Firestore в мобільний додаток
Firestore — це документоорієнтована база даних з real-time слухачами, офлайн-кешем і гнучкими запитами. На відміну від RTDB, підтримує складені індекси, where() по декільком полям, orderBy() з пагінацією. Але вона має свої підводні камені: onSnapshot без limit() на зростаючій колекції поступово збільшує обсяг даних на кожен тик, доки не сповільнить рендеринг.
Структура даних: колекції та підколекції
Базова структура для соціального додатку:
users/{userId}
├── displayName: string
├── photoURL: string
└── posts/{postId} ← підколекція
├── text: string
├── createdAt: Timestamp
└── likes: number
Підколекції правильні для даних, кількість яких не обмежена. Вкладені масиви в документ неправильні для колекцій > 50 елементів: Firestore обмежує розмір документа 1 МБ, і весь документ читається навіть якщо потрібне одне поле.
Підписки та пагінація
import firestore from '@react-native-firebase/firestore';
// Real-time + пагінація
const [posts, setPosts] = useState<Post[]>([]);
const [lastDoc, setLastDoc] = useState<FirebaseFirestoreTypes.DocumentSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const fetchPage = useCallback(async () => {
if (loading) return;
setLoading(true);
let query = firestore()
.collection(`users/${userId}/posts`)
.orderBy('createdAt', 'desc')
.limit(20);
if (lastDoc) query = query.startAfter(lastDoc);
const snapshot = await query.get();
const newPosts = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as Post));
setPosts(prev => [...prev, ...newPosts]);
setLastDoc(snapshot.docs[snapshot.docs.length - 1] ?? null);
setLoading(false);
}, [lastDoc, loading, userId]);
// Real-time: тільки для верхної частини стрічки (без пагінації)
useEffect(() => {
const unsubscribe = firestore()
.collection(`users/${userId}/posts`)
.orderBy('createdAt', 'desc')
.limit(10)
.onSnapshot(snapshot => {
const fresh = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as Post));
setPosts(prev => {
// Об'єднуємо нові real-time дані з пагінованими
const ids = new Set(fresh.map(p => p.id));
return [...fresh, ...prev.filter(p => !ids.has(p.id))];
});
});
return () => unsubscribe();
}, [userId]);
Розділяємо real-time (перша сторінка) і load more (пагінація через get()). onSnapshot на весь список з пагінацією — антипаттерн: кожна зміна в колекції повертає повний новий знімок перших N документів.
Офлайн-кеш
// Включити до будь-якого звернення до Firestore
await firestore().settings({
cacheSizeBytes: firestore.CACHE_SIZE_UNLIMITED, // або конкретний розмір у байтах
persistence: true, // включено за замовчуванням на мобільних
});
При офлайні onSnapshot продовжує працювати, повертаючи дані з кешу з snapshot.metadata.fromCache === true. Записи буферизуються і відправляються при відновленні мережі.
Для явного читання з кешу без мережевого запиту:
const snapshot = await firestore()
.collection('posts')
.doc(postId)
.get({ source: 'cache' }); // 'cache' | 'server' | 'default'
Складені індекси
Запит з where + orderBy по різним полям вимагає складеного індексу. Firestore автоматично пропонує його при першій помилці в development (посилання в консоль). У production налаштуйте індекси в firestore.indexes.json заздалегідь:
{
"indexes": [
{
"collectionGroup": "posts",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "userId", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
}
]
}
Без індексу Firestore повертає FAILED_PRECONDITION помилку. Час створення індексу — від кількох хвилин до годин на великих колекціях.
Правила безпеки Firestore
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /users/{userId}/posts/{postId} {
allow read: if request.auth != null;
allow write: if request.auth.uid == userId
&& request.resource.data.keys().hasAll(['text', 'createdAt'])
&& request.resource.data.text is string
&& request.resource.data.text.size() <= 2000;
}
}
}
Валідуйте типи та розміри в правилах, а не тільки в коді клієнта.
Транзакції та batch writes
// Транзакція: атомарна операція переведення лайку
await firestore().runTransaction(async transaction => {
const postRef = firestore().doc(`posts/${postId}`);
const userLikeRef = firestore().doc(`userLikes/${userId}_${postId}`);
const [postSnap, likeSnap] = await Promise.all([
transaction.get(postRef),
transaction.get(userLikeRef),
]);
if (likeSnap.exists()) {
transaction.delete(userLikeRef);
transaction.update(postRef, { likes: firestore.FieldValue.increment(-1) });
} else {
transaction.set(userLikeRef, { userId, postId, createdAt: firestore.FieldValue.serverTimestamp() });
transaction.update(postRef, { likes: firestore.FieldValue.increment(1) });
}
});
FieldValue.increment() — атомарний інкремент без гонки read-modify-write. Без транзакції при одночасних лайках лічильник буде некоректним.
Оцінка
Firestore з офлайн-персистентністю, real-time підписками, пагінацією та правилами безпеки: 2–4 тижні залежно від складності структури даних.







