DEV Community

Cover image for Putting Angular Fire Firestore library to use - I
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Putting Angular Fire Firestore library to use - I

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

Firestore screenshot

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
  ]
})
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
            })
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) => {
            // ...
        })
    );
}
Enter fullscreen mode Exit fullscreen mode

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))
  }
);
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

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);
}

Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

You can always think of collection is the table, and doc 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
    );
}
Enter fullscreen mode Exit fullscreen mode

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
);                          
Enter fullscreen mode Exit fullscreen mode

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
    );
}
Enter fullscreen mode Exit fullscreen mode

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()...) ...)

Find reference here

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;
}

Enter fullscreen mode Exit fullscreen mode

To use it, we pass the where conditions:

// categories/list.component

this.categories$ = this.categoryService.GetCategories(
    [
        {fieldPath: 'key', opStr: '==', value: 'household'}
    ]
);
Enter fullscreen mode Exit fullscreen mode

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;
  })
);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
  }
Enter fullscreen mode Exit fullscreen mode

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.

References:

Top comments (0)