In this series, we're going to setup a project that uses a Firebase Firestore to list, create, and edit documents, in Angular. This article:
- ... is about Angular project architecture, about observables, and promises
- ... is not about Firebase setup or maintenance
- ... is not about firebase authentication (find here)
- ... uses the official
@angular/fire
package
With the latest Angular update, along with Angular Fire, was kind of depressing. I understand the need to be ever-green, I just don't understand throwing away a syntax that works, for another trendy syntax! But, here we are.
Setup
Here find a link to Firebase Firestore setup, and to Angular Fire setup.
Follow along this project on StackBlitz. I removed Firebase configuration so it won't run properly.
Let's create a project that lists cateogires, where every category is made up of:
- name
- key
Like this
The main.ts
contains the bootstrapper as follows:
// main.ts
// pay attention to where the imports are from, it should from @angular/fire
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { getFirestore, provideFirestore } from '@angular/fire/firestore';
const fbApp = () => initializeApp({
//... firebaseConfig here
});
// ... optionally add authentication
const firebaseProviders = [
provideFirebaseApp(fbApp),
provideFirestore(() => getFirestore()),
];
bootstrapApplication(AppComponent, {
providers: [
...firebaseProviders,
// ...otherpoviders
]
})
The Firestore reference
Angular Fire library initializes the db using getFirestore(app);
and makes it available for injection.
In a service; the category service, the class looks like this
// services/category.service.ts
@Injectable({ providedIn: 'root' })
export class CategoryService {
// inject
private db: Firestore = inject(Firestore);
}
The db is ready to be used in categoryService
.
Note about referencing the right SDK
If you run into this error:
"FirebaseError: Type does not match the expected instance. Did you pass a reference from a different Firestore SDK?"
Make sure you were using the latest Firebase libraries. The ones that worked for me were firebase 11.2.0
and @angular/fire 19.0.0
along with @angular/core 19.0.6
minimum.
Get data
Working backwards, what we want to end up with is a component that consumes a returned list as follows:
// a consuming component, like categories list
@Component({
//...
template: `@let cats = categories$ | async`
})
export class CategoryListComponent implements OnInit {
// example
categories$: Observable<ICategory[]>;
private categoryService = inject(CategoryService);
ngOnInit(): void {
// get categories from service, I expecct an observable I can async to
this.categories$ = this.categoryService.GetCategories();
}
}
To get data we use collectionData
which returns and observable. The collection
function itself returns a reference to the collection.
// services/category.service.ts
@Injectable({ providedIn: 'root' })
export class CategoryService {
// inject, or via constructor
private db: Firestore = inject(Firestore);
GetCategories(): Observable<ICategory[]> {
// collectionData returns a hot observable
return collectionData(collection(this.db, 'categories')).pipe(
map((response: any) => {
// map the returnd docs into your ICategory
// i am not including this part here
return Category.NewInstances(response);
})
);
}
}
did you know you can import from
@angular/fire/firestore/lite
instead? This is firestore lite library for simple REST and GRUD actions.
The collectionData
itself is an HOT observable exported from the rxfire library by Firebase. The following list is already wrapped and exported by @angular/fire
// wrapped and exported by angular fire, from rxFire
auditTrail,
collection,
collectionChanges,
collectionCount,
collectionCountSnap,
collectionData,
doc,
docData,
fromRef,
snapToData,
sortedChanges
The returned observable is hot, it's actually so hot, it's cross user, cross browser. Which is not ideal in most of the cases. Under the hood: I spotted this line in the rxfire
library
RxFire ref: import('firebase/database').Query,
Yes, they import from the real-time database!
One way to fix that is to pipe to take(1)
or first()
// categories.service
GetCategories(): Observable<ICategory[]> {
// collectionData returns a hot observable
return collectionData(collection(this.db, 'categories')).pipe(
// turn off
first(),
map((response: any) => {
// ...
})
);
}
Later we'll figure out a better way.
Getting the document id
The above returns an array of categories without document id.. Let's compare the official documentation with that of AngularFire. According to Firestore documentation
// as documented
getDocs(collection(this.db, 'categories')).then(
(s) => {
// this returns .id, and .data()
s.forEach(f => console.log(f.id))
}
);
Our solution uses rxFire collectionData
, which under the hood is an observable wrapped around DocumentSnapshot The function expects options
that can have a mapper to the idField
, which is a way to wrap both id
and data()
in one object. One of Firebase's mysteries!
// services/category.service.ts
GetCategories(): Observable<ICategory[]> {
// map id as well with idField
return collectionData(collection(this.db, 'categories'), {idField: 'id'}).pipe(
// turn off hot observable
first(),
// ... map
);
}
We can use the result to build our list, with links to every category.
// category/list.component
template:`@let cats = categories$ | async;
<ul>
<li *ngFor="let cat of cats">
<a (click)="select(cat)">{{cat.name}}</a>
</li>
</ul>
<div class="c-6 box bg-white" *ngIf="selected$ | async as cat">
selected Category: {{cat | json}}
</div>
`
selected$: Observable<ICategory>;
select(category: ICategory) {
// to implement GetCategory
this.selected$ = this.categoryService.GetCategory(category.id);
}
Get by document ID
To get a single category by document ID, according to AngulareFire, you just pass categories/:id
. But that isn't how it looks.
I get a different hint in the official documentation
const docRef = doc(db, "cities", "SF");
const docSnap = await getDoc(docRef);
You can always think of
collection
is the table, anddoc
as the single row.
To get a collection, or a sub collection, use collectionData
with URL syntax that has odd number of segments
categories/:id/something
To get a document, anywhere, we must use docData
, and the URL should have even number of segments
categories/:id/something/:somethingid
So if you do not adhere to the number of segments in the URL, for example using collectionData
to get two segments, you will get funny error messages like this:
Collection references must have an odd number of segments, but categories/RG3e5jWzithsEzoPWBag has 2.
The right way is to use doc
and docData
instead.
// category.service
GetCategory(id: string): Observable<ICategory> {
// pass url: categories/:id, or pass arguments separately
return docData(doc(this.db, 'categories', id), { idField: 'id' }).pipe(
first(),
// ... map
);
}
Query
To get a list of categories based on any criteria, we use query
, then collectionData
. The result is always an array.
// category.service
// query to get all categories
const _query = query(collection(this.db, 'categories'))
return collectionData(_query, { idField: 'id' }).pipe(
first(),
// ... map
);
Adding a where condition of multiple fields, the default is an AND operator, the reference format is query(ref, where(), where() ...)
// categories.service
// multiple field where condition
GetCategories(params: IWhere[]): Observable<ICategory[]> {
// turn params into an array of where conditions, then expand it
const _query = query(collection(this.db, 'categories'),
...params.map(n => where(n.fieldPath, n.opStr, n.value))
);
return collectionData(_query).pipe(
first(),
// ... map
);
}
The or
format looks like this: query(ref, or(where(), where() ...)
, and a combination may look like this query(ref, and(where(), where(), or(where(), where()...) ...)
Now the where conditions are made up of three segments: field, operator, value. So in another model file, we define the interface:
// where.model.ts
export type IWhereOp =
| '<'
| '<='
| '=='
| '!=' // be careful, some operators are not free
| '>='
| '>'
| 'array-contains'
| 'in'
| 'array-contains-any'
| 'not-in';
export interface IWhere {
fieldPath: string;
opStr: IWhereOp;
value?: any;
}
To use it, we pass the where conditions:
// categories/list.component
this.categories$ = this.categoryService.GetCategories(
[
{fieldPath: 'key', opStr: '==', value: 'household'}
]
);
This looks ugly all around. Later we'll figure out a more useful way.
Creating a new category is passing the object to addDoc
, but the catch is, there is no observable to watch. This is a Promise. But we want an observable so we can pipe to properly.
At this stage, I want to stop and take a step back. The above solutions used rxfire
observables, which are all hot. It is not ideal to keep hot observables when dealing with a very expensive service like Firestore. We used first
to cool it off. But let's try something different. In order to use with all actions.
Cold observables
Using collectionData
and docData
is a bit disappointing, because not all actions are wrapped inside of it, and they are hot! We can use from
. Or defer
if we use then
directly. Let me show you both ways:
Using from
// categories.service
// getDocs imported from @angular/fire
// get data with "from"
return from(getDocs(q)).pipe(
map((s: any) => {
// s is a querysnapshot that has forEach method
s.forEach(n => console.log(n.data()));
// we'll map later
return s;
})
);
Or you can use then
directly if you remember to defer
in RxJs (because then
invokes an immediate call):
// categories.sevice
// a different way
const docs = () => getDocs(q).then(s => {
console.log(s);
return s;
});
// return a defer in order to subscribe to
return defer(docs)
I like the from
way. So I am sticking to it
Mapping data
Let's figure out a way to:
- map our data properly with document ID
- show a loading effect (yes)
Before we go on, let's see if that fixed the add document mess:
// categories.service
CreateCategory(category: Partial<ICategory>): Observable<any> {
return from(addDoc(collection(this.db, 'categories'), category));
// todo: proper mapping
}
Yup, this works as expected. But there are other ways to add documents. Next Tuesday inshallah. 😴
Did you know that the IOF destroyed 80% of Gaza? But Gaza will recover.
Top comments (0)