Home
> Angular Todo App with Firebase

Angular
Angular Todo App with Firebase

24 min read

Jonathan Gamble

jdgamble555 on Sunday, April 14, 2024 (last modified on Sunday, April 14, 2024)

Using Angular with Firebase has changed slightly since version 16. While we navigate closer to standalone components, the library is evolving. It will soon merge with Google’s internal Framework, Wiz. There are cleaner ways to do things in Angular, but you still have options depending on your needs. This is the same old Todo app but in Angular.

TL;DR#

This post teaches you the setup for Angular with AngularFire so you can use Firebase with your Angular app. This app shows you how to use Firebase with Angular WITHOUT SIGNALS. There are too many bad articles where an observable is not unsubscribed to, or the integration is half-baked.

🗝️ The Traditional Approach (Observables)#

I rewrote this article to focus on the old way of doing things. They are still applicable today, and older code bases should be refactored to use them correctly if they can’t use the modern version with Signals. This version covers Observables, Services, Zone.js Change Detection, and the traditional conditional directives.

🔑 The Modern Approach (Signals)#

For the modern approach, see the sister article for Analog Todo App with Firebase. That version uses Signals, Injectable Tokens, and Custom Hooks. No Zone.js worries.

📗 The Realistic Approach (Mix-and-Match)#

Your app will probably use a combination of techniques, so I wrote this article to be opinionated one way and the Analog version to be bullish on Modern Techniques.

Environment Variables#

The old Angular way is to use an environment.ts file to configure your app. We now know this is bad practice. We don’t want our keys, even public keys, easily accessible on GitHub. The easiest way to allow .env file support in Angular 16 and up is to use @ngx-env/builder. You could also consider using Webpack, but it is not as easy to configure.

	ng add @ngx-env/builder

You need to add the NG_APP_FIREBASE_CONFIG variable to your .env file. You can use your variables anywhere in your project from your .env files if the prefix is NG_APP_.

	NG_APP_FIREBASE_CONFIG={"apiKey":"...","authDomain":"..."...}

Note: The keys must be in double quotes as well.

	const firebaseConfig = JSON.parse(
  import.meta.env['NG_APP_FIREBASE_CONFIG']
);

For later use, you can parse the data at the top of your app.config.ts file.

AngularFire#

First, you need to install AngularFire. This is necessary in Angular due to the big JavaScript wrapper called Zone.js. It is considered optional in future versions of Angular and possibly removed. It creates a large overhead that will be unnecessary now that we have Signals.

Add the AngularFire package.

	ng add @angular/fire

Because we use standalone components, we need to put our Firebase providers in importProvidersFrom in our app.config.ts file so that they work correctly. Your app will have all of them in separate functions, but you can delete the generated version and simplify it to look pretty.

	export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideClientHydration(),
    // Angular Providers
    importProvidersFrom(
      provideFirebaseApp(() => initializeApp(firebaseConfig)),
      provideFirestore(() => getFirestore()),
      provideAuth(() => getAuth()),
      // provideStorage(() => getStorage()),
      // provideAnalytics(() => getAnalytics()),
    )
  ]
};

User Service#

We must first handle the user state, as we can’t view our todos without a logged-in user. To do this, we must create a user service.

	ng g s services/user

We will match the User type in Firebase but only need a few variables. We must make a type in TypeScript.

	export interface UserData {
  photoURL: string | null;
  uid: string;
  displayName: string | null;
  email: string | null;
};

type UserType = {
  loading: boolean;
  data: UserData | null;
};

I also created a UserType because I like to handle loading states.

Behavior Subjects#

We must share the user state among several components. As our app gets bigger, this could be many components. We use onIdTokenChanged, the better version of onAuthStateChanged, to get the user state in real time. It gets called like an observable gets called, but we can’t use the data in our template directly without subscribing to it. We can either convert it to an observable or a BehaviorSubject. An observable does not share state well with all subscribers, while a BehaviorSubject does this well.

	  private _user = new BehaviorSubject<UserType>({
    loading: true,
    data: null
  });
  
  user = this._user.asObservable();

The Subject is private, but shared to your template(s) as an observable so it can be subscribed to with the async pipe.

⚠️ Note on Observables#

If you want to use an actual Observable, use the RxJS shareReplay pipe to share the state correctly without any problems.

	user.pipe(
  shareReplay({ 
    bufferSize: 1, 
    refCount: true
   })
);

The refCount flag will ensure your observable gets unsubscribed when there are no subscribers, and the bufferSize will ensure you get the last version to all your subscribers. This version does not use that, but you need to know it is an option and SHOULD be used if you do not use BehaviorSubject.

Getting User Data#

We create a getUser() function to handle the subscription and use the this._user.next() function to handle the data changes to the Behavior Subject. The function gets called by setting it to the _subscription variable, which will get unsubscribed to in the ngOnDestroy() function.

	  private _user = new BehaviorSubject<UserType>({
    loading: true,
    data: null
  });
  
  constructor(
    private auth: Auth
  ) { }
  
  user = this._user.asObservable();

  private _subscription = this._getUser();

  private _getUser() {
    return onIdTokenChanged(
      this.auth,
      (_user: User | null) => {

        if (!_user) {
          this._user.next({
            loading: false,
            data: null
          });
          return;
        }

        // map data to user data type
        const {
          photoURL,
          uid,
          displayName,
          email
        } = _user;
        const data = {
          photoURL,
          uid,
          displayName,
          email
        };

        // print data in dev mode
        if (isDevMode()) {
          console.log(data);
        }

        // set store
        this._user.next({
          data,
          loading: false
        });
      });
  }

  // ⚠️ ALWAYS HANDLE UNSUBSCRIBE!!!
  ngOnDestroy(): void {
    this._subscription();
  }

Declarative vs Imperative Programming#

When we handle subscribing and unsubscribing manually, it is called imperative programming. When we let the async pipe do this for us, it is called declarative programming. Generally, declarative programming is better because you can make fewer mistakes when handling unsubscribing. Everything is also in one place. However, declarative programming can take more work to learn. While I normally prefer declarative programming techniques, I like how easily a BehaviorSubject can handle your sharing. Technically we are using both here.

Login#

Finally, we need to add our login methods to the service. This app uses Login With Google to keep it simple.

	  login() {
    signInWithPopup(this.auth, new GoogleAuthProvider());
  }

  logout() {
    signOut(this.auth);
  }

Todo Service#

First, we need the types.

	export interface TodoItem {
  id: string;
  text: string;
  complete: boolean;
  created: Date;
  uid: string;
};

export type TodoType = {
  loading: boolean;
  data: TodoItem[];
};

Next, generate the service.

	ng g s services/todos

This service follows the same pattern as our user service, except that we require the user to subscribe to the todos collection by adding the user service.

	private _todos = new BehaviorSubject<TodoType>({
  loading: true,
  data: []
});

constructor(
  private zone: NgZone, // keep reading for why this is here
  private db: Firestore,
  private us: UserService
) {}

todos = this._todos.asObservable();

getTodosFromUser()#

Subscribing to the todos collection is more complicated. We need the user ID to subscribe, so we can’t subscribe unless a user is logged in.

	private _getTodosFromUser(uid: string) {
    // query realtime todo list
    return new Observable<QuerySnapshot<TodoItem>>(
      (subscriber) => onSnapshot(
        query(
          collection(this.db, 'todos'),
          where('uid', '==', uid),
          orderBy('created')
        ).withConverter(todoConverter),
        subscriber // will be changed... keep reading
      )
    )
      .pipe(
        map((arr) => {

          /**
           * Note: Will get triggered 2x on add 
           * 1 - for optimistic update
           * 2 - update real date from server date
          */

          if (arr.empty) {
            return {
              loading: false,
              data: []
            };
          }
          const data = arr.docs
            .map((snap) => snap.data());

          // print data in dev mode
          if (isDevMode()) {
            console.log(data);
          }
          return {
            loading: false,
            data
          };
        })
      );
  }

Observable#

We want to be able to subscribe and unsubscribe to the collection on-demand, and RxJS has an operator switchMap for that. However, we have to convert the onSnapshot to an observable. We use pipe and map to modify the data.

Data Converter#

While I don’t always like data converters, they simplify typing with complex observables like this.

	const todoConverter = {
  toFirestore(value: WithFieldValue<TodoItem>) {
    return value;
  },
  fromFirestore(
    snapshot: QueryDocumentSnapshot
  ) {
    const data = snapshot.data({
      serverTimestamps: 'estimate'
    });
    const created = data['created'] as Timestamp;
    return {
      ...data,
      created: created.toDate(),
      id: snapshot.id
    } as TodoItem;
  }
};

I need to convert the timestamp to a date, handle ID fields, and handle the optimistic updates using serverTimestamp(). See Firestore Dates and Timestamps and Does Firestore Need Data Converters.

⚠️ Zone.js#

Perhaps the most complicated thing about Angular, which will soon be optional, is using Zone.js. In a nutshell, Angular puts all tasks in a Zone, while other tasks run outside of Angular. AngularFire converts all Firebase functions to run inside the main Zone. When we create the Observable, Angular does not keep it inside the Angular Zone in this case. We have to manually declare it, so that the next function of the BehaviorSubject triggers change detection. If you have no idea what I’m talking about, don’t worry. Use effect() with signals() instead in the Analog Todo With Firebase version. This is for advanced users who are stuck using old techniques. In order to combat the issue, we bring back the change detection by running the next() function inside the zone using this.zone.run() function.

	  private _getTodosFromUser(uid: string) {
    // query realtime todo list
    return new Observable<QuerySnapshot<TodoItem>>(
      (subscriber) => onSnapshot(
        query(
          collection(this.db, 'todos'),
          where('uid', '==', uid),
          orderBy('created')
        ).withConverter(todoConverter),
        
        // there is no complete function as real-time is app-lived
        snapshot => this.zone.run(() => subscriber.next(snapshot)),
        error => this.zone.run(() => subscriber.error(error))
      )
    )

This alone is a reason enough NOT to use this version.

Getting Todos#

The getTodosFromUser() function is automatically subscribed to WHEN there is a user. The RxJS switchMap pipe makes this easy. It returns a new observable to subscribe to. For cases without a user, we can make an observable from any data using of(). We subscribe manually to this function and pass the this._todos subscriber, which is the BehaviorSubject. Keep the functions we don’t need access to outside the class private.

	  private _subscription = this._getTodos();

  private _getTodos() {
    // get todos from user observable
    return this.us.user.pipe(
      switchMap((_user) => {

        // get todos if user
        if (_user.data) {
          return this._getTodosFromUser(
            _user.data.uid
          );
        }
        // otherwise return empty
        return of({
          loading: false,
          data: []
        });
      })
    ).subscribe(this._todos);
  }
  
  ngOnDestroy(): void {
    this._subscription.unsubscribe();
  }

Always handle unsubscribe. Always.

Todo CRUD#

My example app doesn’t use Angular Reactive Forms, but you would probably want to if you have anything but simplicity.

	  addTodo = (e: SubmitEvent) => {

    e.preventDefault();

    const userData = await firstValueFrom(
      this.us.user
    );

    if (!userData.data) {
      throw 'No User!';
    }

    // get and reset form
    const target = e.target as HTMLFormElement;
    const form = new FormData(target);
    const { task } = Object.fromEntries(form);

    if (typeof task !== 'string') {
        return;
    }

    // reset form
    target.reset();

    addDoc(collection(this.db, 'todos'), {
        uid,
        text: task,
        complete: false,
        created: serverTimestamp()
    });
  }

You must prevent the todo from being added if there is no user, but this should be impossible as you can’t view the form without a logged-in user. However, errors can happen. We convert the user observable to a promise and wait on it with firstValueFrom to get the latest value.

For the sake of completeness, you could also use the currentUser variable in the Firebase Auth.

	    
  constructor(
    private zone: NgZone,
    private db: Firestore,
    private us: UserService,
    private auth: Auth
  ) {}
    
    ...
    
    const user = this.auth.currentUser;

    if (!user) {
      throw 'No User!';
    }

The update and delete functions are as expected.

	  updateTodo = (id: string, complete: boolean) => {
    updateDoc(doc(this.db, 'todos', id), { complete });
  }

  deleteTodo = (id: string) => {
    deleteDoc(doc(this.db, 'todos', id));
  }

Components#

You can use your services in any component by adding them to the constructor.

	constructor(public us: UserService) { }

Note: The modern way of injectors is covered in the Analog version.

	user = inject(UserService).user$;

Subscribing#

You can get the data in your components by using the async pipe. You have to add AsyncPipe to your imports in the components that use them.

	@Component({
  selector: 'app-home',
  standalone: true,
  imports: [
    ProfileComponent,
    AsyncPipe,
    CommonModule
  ],
  templateUrl: './home.component.html'
})

Then you check for the state here using the ngIf directives. Modern versions use control @if loops.

	<div class="text-center">
    <h1 class="text-3xl font-semibold my-3">Angular Firebase Todo App</h1>
    <ng-container *ngIf="us.user | async as user">
        <ng-container *ngIf="user.loading; else data">
            <p>Loading...</p>
        </ng-container>
        <ng-template #data>
            <ng-container *ngIf="user.data; else no_user">
                <app-profile />
            </ng-container>
        </ng-template>
    </ng-container>
    <ng-template #no_user>
        <button type="button" class="border p-2 rounded-md text-white bg-red-600" (click)="us.login()">
            Signin with Google
        </button>
    </ng-template>
</div>

There is nothing fun about ngIf, but they are still usable in Angular Apps and have their purpose. The async pipe is a declarative approach.

About Page#

I also created an About page to show you how to load Firebase from the Server. The Analog Version will cover this.

The rest of the app is standard Angular techniques. I will not be covering them, as this post is about Firebase.

Deployment#

Deploying an Angular app is not easy, but deploying an Angular Fire app is even more brutal. Google obviously wants you to use Firebase Functions. You can use firebase init hosting or ng deploy and follow the instructions. You may need to set prerender, to false in angular.json, and comment out the port in server.ts, as this is a known issue.

	// const port = process.env['PORT'] || 4000;
const port = 4000;

Edge Deployment#

Currently, there is no efficient way to deploy Angular on Edge Network Functions without using Analog or Vite directly. Hopefully, this will change soon.

Should I Use This?#

Nope. I wouldn’t. I updated this Article for learning reference and for advanced users who want to use RxJS correctly. There are also optional ways of doing things you may find useful here, as Angular keeps evolving.

Repo: GitHub

Serverless Demo: Vercel Demo

For modern techniques, read Analog Todo App with Firebase.


Related Posts

Newsletter

Get updates and the latest coding blog posts!

© 2024 Code.Build