To use Cloud Firestore in a React app, install the firebase package, initialize it once with initializeApp() and getFirestore(), then call the modular functions (addDoc, getDocs, updateDoc, deleteDoc) from your components. For live data, subscribe with onSnapshot() inside useEffect and return the unsubscribe function as the cleanup. Protect the data with Firestore security rules, because the browser talks to the database directly.
This guide uses the current modular SDK (firebase 13) with React 19. The 2023 version of this post used the old namespaced API (import firebase from 'firebase/app' and firebase.firestore()), which stopped working with those imports in SDK version 9 and now survives only in a compatibility layer, so every example has been rewritten. I ran all of them against the Firebase Local Emulator Suite: a CRUD and query script, a security rules test suite, and a small React todo app driven in a headless browser.
What Is Cloud Firestore?
Cloud Firestore is Firebase’s NoSQL document database. Data is stored as documents (JSON-like objects with fields) inside collections, and a document can contain subcollections, for example users/{uid}/todos/{todoId}. Web and mobile apps can read and write it directly through the SDK, receive changes in real time, and keep working offline. If you have used the Realtime Database, see security rules for the Realtime Database: Firestore has richer queries and a different rules language.
Step 1: Create a Firebase Project and Database
- In the Firebase console, create a project (or open an existing one), then add a Web app to get its config object.
- Go to Firestore and create a database. Choose the Standard edition unless you specifically need Enterprise features, pick a location (it can’t be changed later), and start in production mode, which denies all client access until you write rules.
- If your app needs sign-in, enable a provider under Authentication. My Firebase Authentication with React guide covers this step in detail.
Avoid “test mode” for anything real: it lets anyone read and overwrite your database until the time limit expires.
Step 2: Install and Initialize Firebase
npm install firebasePut the config values in a .env file (with Vite, variables must start with VITE_) and initialize Firebase in one module that the rest of the app imports:
import { initializeApp } from 'firebase/app';
import { getAuth, connectAuthEmulator } from 'firebase/auth';
import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore';
const app = initializeApp({
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
});
export const auth = getAuth(app);
export const db = getFirestore(app);
// Use the local emulators while developing
if (import.meta.env.VITE_USE_EMULATORS === 'true') {
connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true });
connectFirestoreEmulator(db, '127.0.0.1', 8080);
}The Firebase web config, including apiKey, is not secret; it only identifies your project. Your data is protected by security rules (Step 6), not by hiding these values. I explain why in login authentication without a backend.
Step 3: Create, Read, Update and Delete Documents
Firestore functions take a reference built with collection() or doc(). Here are all the basic operations, run against the emulator:
import {
collection, doc, addDoc, setDoc, getDoc, getDocs, updateDoc, deleteDoc,
query, where, orderBy, limit, startAfter, onSnapshot,
serverTimestamp, increment, arrayUnion, writeBatch,
} from 'firebase/firestore';
import { db } from './firebase';
// CREATE: addDoc generates an ID; setDoc uses one you choose
const ref = await addDoc(collection(db, 'products'), {
name: 'Keyboard', price: 49, stock: 10, tags: ['usb'], createdAt: serverTimestamp(),
});
console.log('addDoc id:', ref.id);
await setDoc(doc(db, 'products', 'mouse'), { name: 'Mouse', price: 19, stock: 0, tags: [], createdAt: serverTimestamp() });
// READ one document
const snap = await getDoc(doc(db, 'products', 'mouse'));
console.log('getDoc:', snap.exists(), snap.data().name);
// UPDATE specific fields
await updateDoc(ref, { price: 45, stock: increment(-1), tags: arrayUnion('wireless') });
const updated = (await getDoc(ref)).data();
console.log('after updateDoc:', updated.price, updated.stock, updated.tags);
// setDoc without merge replaces the whole document
await setDoc(doc(db, 'products', 'mouse'), { price: 17 });
console.log('setDoc (no merge):', (await getDoc(doc(db, 'products', 'mouse'))).data());
await setDoc(doc(db, 'products', 'mouse'), { name: 'Mouse', stock: 5 }, { merge: true });
console.log('setDoc merge:', (await getDoc(doc(db, 'products', 'mouse'))).data());
// Batch write: all or nothing
const batch = writeBatch(db);
for (const [i, name] of ['Monitor', 'Webcam', 'Headset', 'Dock', 'Cable'].entries()) {
batch.set(doc(db, 'products', name.toLowerCase()), { name, price: 20 + i * 30, stock: i, tags: [] });
}
await batch.commit();Output:
addDoc id: rYEAJVjSGYdZqRXoDxP5
getDoc: true Mouse
after updateDoc: 45 9 [ 'usb', 'wireless' ]
setDoc (no merge): { price: 17 }
setDoc merge: { price: 17, name: 'Mouse', stock: 5 }| Function | What it does |
|---|---|
addDoc(collectionRef, data) | Creates a document with an auto-generated ID |
setDoc(docRef, data) | Creates or replaces a document with an ID you choose |
setDoc(docRef, data, { merge: true }) | Creates or updates only the given fields |
getDoc(docRef) / getDocs(query) | Reads one document / all documents matching a query |
updateDoc(docRef, data) | Updates fields; fails if the document doesn’t exist |
deleteDoc(docRef) | Deletes a document (but not its subcollections) |
writeBatch(db) | Groups several writes that succeed or fail together |
Note the trap in the output: setDoc without { merge: true } replaced the whole “Mouse” document, removing its name and stock. Use updateDoc or merge when you only want to change some fields. Helpers such as serverTimestamp(), increment() and arrayUnion() let the server apply changes safely even when several clients write at once.
Step 4: Query, Sort and Paginate
const cheapInStock = query(
collection(db, 'products'),
where('price', '<', 100),
where('stock', '>', 0),
orderBy('price'),
);
console.log('price < 100 and stock > 0:', (await getDocs(cheapInStock)).docs.map((d) => `${d.data().name} $${d.data().price}`));
// PAGINATION with a cursor
const page1 = await getDocs(query(collection(db, 'products'), orderBy('price'), limit(3)));
const last = page1.docs[page1.docs.length - 1];
const page2 = await getDocs(query(collection(db, 'products'), orderBy('price'), startAfter(last), limit(3)));
console.log('page 1:', page1.docs.map((d) => d.data().name), 'page 2:', page2.docs.map((d) => d.data().name));price < 100 and stock > 0: [ 'Mouse $17', 'Keyboard $45', 'Webcam $50', 'Headset $80' ]
page 1: [ 'Mouse', 'Monitor', 'Keyboard' ] page 2: [ 'Webcam', 'Headset', 'Dock' ]Things to know about Firestore queries (see the queries documentation):
- Firestore supports range and inequality filters on multiple fields in one query (up to 10), as above. With an inequality filter, your first
orderBymust be on an inequality field. - Queries that combine filters and sorting on different fields need a composite index in production. The emulator doesn’t enforce indexes, so the query above ran locally without one; in a real project, the first run fails with an error containing a link that creates the index for you.
- Use cursors (
startAfterwith the last document of the previous page) for pagination. There is no efficient “skip 100 documents” in Firestore. For a UI pattern, see pagination in a dynamic React table. - Firestore has no full-text search. For searching inside text, use an extension or an external search service.
Step 5: Real-Time Updates in React with onSnapshot
onSnapshot calls your function immediately with the current data and again whenever a matching document changes. In React, subscribe in useEffect and return the unsubscribe function, so the listener stops when the component unmounts or its inputs change. A custom hook keeps this out of your components:
import { useEffect, useState } from 'react';
import { collection, query, orderBy, onSnapshot } from 'firebase/firestore';
import { db } from './firebase';
export function useTodos(uid) {
const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!uid) return;
const q = query(collection(db, 'users', uid, 'todos'), orderBy('createdAt', 'asc'));
// onSnapshot fires once with the current data, then on every change
const unsubscribe = onSnapshot(
q,
(snapshot) => {
setTodos(snapshot.docs.map((d) => ({ id: d.id, ...d.data() })));
setLoading(false);
},
(err) => {
setError(err);
setLoading(false);
},
);
return unsubscribe; // stop listening when the component unmounts or uid changes
}, [uid]);
return { todos, loading, error };
}The write functions, kept in their own module:
import { collection, doc, addDoc, updateDoc, deleteDoc, serverTimestamp } from 'firebase/firestore';
import { db } from './firebase';
const todosRef = (uid) => collection(db, 'users', uid, 'todos');
export const addTodo = (uid, title) =>
addDoc(todosRef(uid), { title, done: false, createdAt: serverTimestamp() });
export const toggleTodo = (uid, todo) =>
updateDoc(doc(db, 'users', uid, 'todos', todo.id), { done: !todo.done });
export const removeTodo = (uid, id) =>
deleteDoc(doc(db, 'users', uid, 'todos', id));And a component that uses them. Notice that it never updates the list itself after adding or deleting: the listener receives every change, including the app’s own writes, and re-renders the list.
import { useEffect, useState } from 'react';
import { onAuthStateChanged, signInAnonymously } from 'firebase/auth';
import { auth } from './firebase';
import { useTodos } from './useTodos';
import { addTodo, toggleTodo, removeTodo } from './todos';
export default function App() {
const [user, setUser] = useState(null);
useEffect(() => {
return onAuthStateChanged(auth, (u) => {
if (u) setUser(u);
else signInAnonymously(auth); // use your real sign-in flow here
});
}, []);
if (!user) return <p>Signing in...</p>;
return <TodoList uid={user.uid} />;
}
function TodoList({ uid }) {
const { todos, loading, error } = useTodos(uid);
const [title, setTitle] = useState('');
async function handleSubmit(e) {
e.preventDefault();
if (!title.trim()) return;
await addTodo(uid, title.trim());
setTitle('');
}
if (loading) return <p>Loading...</p>;
if (error) return <p role="alert">Error: {error.code}</p>;
return (
<main>
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="New todo" />
<button>Add</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label>
<input type="checkbox" checked={todo.done} onChange={() => toggleTodo(uid, todo)} />
{todo.title}
</label>
<button onClick={() => removeTodo(uid, todo.id)} aria-label={`Delete ${todo.title}`}>×</button>
</li>
))}
</ul>
<p data-uid={uid}>{todos.filter((t) => !t.done).length} left</p>
</main>
);
}How I tested it
I built the app with Vite and ran it against the Auth and Firestore emulators with the security rules from the next step, then drove it with Puppeteer in headless Brave:
| Step | List shown in the app | Counter |
|---|---|---|
| Signed in (anonymously) | empty | 0 left |
| Added two todos in the form | [ ] Buy milk, [ ] Write blog post | 2 left |
| Ticked the first one | [x] Buy milk, [ ] Write blog post | 1 left |
| Another client wrote a todo for the same user | …plus [ ] Added on another device | 2 left |
| Deleted the second one | [x] Buy milk, [ ] Added on another device | 1 left |
| Reloaded the page | same two todos | 1 left |
The write from another client appeared without any refresh, which is the main reason to use onSnapshot instead of getDocs. The app ran in React Strict Mode, which mounts effects twice in development; because the effect returns unsubscribe, the extra listener was cleaned up and there were no console errors. For one-off reads (a report, a search result), getDocs or a data library such as TanStack Query is simpler; see how to fetch data in React.
Step 6: Secure Your Data with Security Rules
Because the browser talks to Firestore directly, security rules are your backend. They run on Google’s servers for every request and can’t be bypassed from the client. These rules give each user a private todo list, validate the data, and make a product catalogue read-only:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Each signed-in user can only see and change their own todos
match /users/{userId}/todos/{todoId} {
allow read, delete: if request.auth != null && request.auth.uid == userId;
allow create, update: if request.auth != null
&& request.auth.uid == userId
&& request.resource.data.keys().hasOnly(['title', 'done', 'createdAt'])
&& request.resource.data.title is string
&& request.resource.data.title.size() > 0
&& request.resource.data.title.size() <= 200
&& request.resource.data.done is bool;
}
// Public read-only catalogue
match /products/{productId} {
allow read: if true;
allow write: if false;
}
}
}Test rules before you deploy them. The @firebase/rules-unit-testing library runs requests as different users against the emulator:
import fs from 'node:fs';
import { initializeTestEnvironment, assertSucceeds, assertFails } from '@firebase/rules-unit-testing';
import { doc, setDoc, getDoc, getDocs, collection, serverTimestamp } from 'firebase/firestore';
const env = await initializeTestEnvironment({
projectId: 'demo-rules',
firestore: { rules: fs.readFileSync('firestore.rules', 'utf8'), host: '127.0.0.1', port: 8080 },
});
const alice = env.authenticatedContext('alice').firestore();
const bob = env.authenticatedContext('bob').firestore();
const guest = env.unauthenticatedContext().firestore();
await assertSucceeds(setDoc(doc(alice, 'users/alice/todos/t1'), { title: 'Buy milk', done: false, createdAt: serverTimestamp() }));
await assertFails(getDoc(doc(bob, 'users/alice/todos/t1')));
// ...| Check | Result |
|---|---|
| Alice creates and reads her own todos | Allowed |
| Bob reads or writes Alice’s todos | Denied |
| Signed-out user reads a todo | Denied |
Empty title, non-boolean done, or an extra field | Denied |
| Anyone reads products | Allowed |
| A signed-in user writes a product | Denied |
All ten checks passed. A few rules of thumb: never deploy allow read, write: if true; scope data by request.auth.uid; validate the shape of writes with request.resource.data; and remember that rules are not filters: a query must only ask for documents the user is allowed to read, or the whole query fails.
Running Firestore Locally with the Emulator
npm install -g firebase-tools
firebase init emulators # choose Authentication and Firestore
firebase emulators:startPoint your app at it with connectFirestoreEmulator and connectAuthEmulator, as in the firebase.js file above. The emulator is free, resets on restart, loads your firestore.rules, and is the easiest way to test rules and listeners. It needs Java installed. Use a project ID starting with demo- to make sure nothing can reach a real project.
Common Problems
- “Missing or insufficient permissions”: your rules deny the request. Check that the user is signed in before the query runs and that the query only targets documents the rules allow.
- “The query requires an index”: open the link in the error to create the composite index, or define it in
firestore.indexes.json. - Data disappears from a document: you used
setDocwithout{ merge: true }. - Listeners keep firing after leaving a page, or costs grow: you didn’t return the unsubscribe function from
useEffect. firebase.firestore is not a function: that’s the old namespaced API from pre-2021 tutorials; use the modular imports shown here.- Large bills from reads: every document returned counts as a read, including each document re-sent to a listener. Use
limit(), paginate, and avoid listening to whole large collections.
Conclusion
Using Cloud Firestore in React comes down to a few pieces: one firebase.js module that initializes the app, small functions that call addDoc, updateDoc and deleteDoc, a custom hook that subscribes with onSnapshot and unsubscribes on cleanup, and security rules that scope each document to the right user. Build and test it against the emulator first, then deploy your rules and indexes with the Firebase CLI. If your data starts in Cloud Storage, see how to move data from Firebase Storage to Firestore.
FAQs
How do I connect Cloud Firestore to a React app?
Install the firebase package, call initializeApp with your web config and getFirestore to get a db instance, and export it from one module. Components then import db and call functions such as getDocs, addDoc and onSnapshot.
How do I get real-time data from Firestore in React?
Call onSnapshot on a document or query inside useEffect, store the results in state, and return the unsubscribe function from the effect so the listener stops when the component unmounts.
What is the difference between setDoc and updateDoc?
setDoc creates or replaces a whole document unless you pass { merge: true }. updateDoc changes only the given fields and fails if the document does not exist.
Is it safe to put the Firebase config in my React code?
Yes. The web config, including the API key, identifies your project and is meant to be public. Protect your data with Firestore security rules and Firebase Authentication.
Why does my Firestore query say it requires an index?
Queries that combine filters or sorting on several fields need a composite index. Follow the link in the error message to create it, or add it to firestore.indexes.json and deploy with the Firebase CLI.
Can I use Firestore without a backend server?
Yes. The client SDK reads and writes directly, and security rules enforce who can access what. Use Cloud Functions only for logic that must not run in the browser, such as using secret API keys.
Does Firestore work offline in a web app?
It can. Enable a persistent cache with initializeFirestore(app, { localCache: persistentLocalCache() }) and the SDK serves cached data and queues writes until the connection returns.








