Home
> Analog Todo App with Firebase
Get updates on future FREE course and blog posts!
Subscribe

Analog Todo App with Firebase

22 min read

Jonathan Gamble

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

AnalogJS is what Angular should be. It definitely makes handling server-side applications easier. It fixes all the deployment problems with Angular. I rebuilt my Todo app again, this time for Analog. I actually rebuilt the old Angular Version using Observables, Classes, and Injectables… Yuck! I decided to incorporate all the modern Angular techniques in a modern Angular Framework.

TL;DR#

Building this app with Analog was a breeze. I realized how important Signals are after rebuilding the Angular Version using older techniques. Zoneless Angular will be amazing.

🔑 The Modern Approach (Signals)#

We will see a page router, injector tokens, transfer state utilities, new @if conditional controls, built-in .env support, easy deployment, and the possibility of deploying to Edge Functions.

🗝️ The Traditional Approach (Observables)#

I rewrote my old article to focus on the old way of doing things. While still applicable today, 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. See Angular Todo App with Firebase.

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

Your app will probably use a combination of techniques. This article explains the new Angular techniques for using Firebase, but you will probably use both techniques in a complex application.

Setup#

Add AngularFire.

	ng add @angular/fire

Add your Firebase configuration to your .env file. Make sure the keys have double quotes.

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

Add the correct providers to your application in app.config.ts.

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

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

Generate the Pages#

Generate a home component and an about component.

	ng g c components/about
// or
ng g c components/home -t -s

✏️ Use -s for inline styles (good for tailwind) and—t for inline templates.

We will have two pages, the Todo Page, and the About Page. Create pages/index.page.ts and pages/about.page.ts. I like to link them to the components I use to keep my features in one place.

	// index.page.ts
import { Component } from '@angular/core';
import { HomeComponent } from '@components/home/home.component';

@Component({
  selector: 'app-index',
  standalone: true,
  imports: [HomeComponent],
  template: ` <app-home /> `
})
export default class IndexComponent { }

You should also create a resolver for the About page.

	ng g r components/about

And you can import it into the About route with the routeMeta keyword.

	import { RouteMeta } from '@analogjs/router';
import { Component } from '@angular/core';
import AboutComponent from '@components/about/about.component';
import { aboutResolver } from '@components/about/about.resolver';

export const routeMeta: RouteMeta = {
  resolve: { data: aboutResolver }
};

@Component({
  selector: 'app-route',
  standalone: true,
  imports: [AboutComponent],
  template: ` <app-about /> `
})
export default class AboutRoute { }

If you’re not using the Page Directory, you should import the router in your app.routes.ts file instead.

	export const routes: Routes = [
    { path: '', component: HomeComponent },
    { path: 'about', component: AboutComponent, resolve: { data: aboutResolver } }
];

User Service#

Create the service. If you don’t want the spec file, add --skip-tests.

	ng g s user --skip-tests

However, we will not use the classes; we will use injection tokens. Delete the contents of the file and add the user type.

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

Injection Token#

An injection token is a functional version of a service. A class cannot use Tree Shaking, so all methods are always imported. A function can be imported where and only where it is needed. Injection tokens are similar to hooks in other frameworks, except they can be shared anywhere. They have three arguments (technically two, one, and an object):

  1. The name of the token. This is like the name of the context.
  2. The provider. Use root, and it can be shared everywhere.
  3. The factory() function runs only once.

Auth Token#

Our first token is the auth token. We don’t want this to load on the browser, so we can check for that with PLATFORM_ID.

	export const FIREBASE_AUTH = new InjectionToken<Auth | null>(
  'firebase-auth',
  {
    providedIn: 'root',
    factory() {
      const platformID = inject(PLATFORM_ID);
      if (isPlatformBrowser(platformID)) {
        return inject(Auth);
      }
      return null;
    }
  }
);

User Token#

We need to share the user state from onIdTokenChanged, better than onAuthStateChanged because it handles token changes as well, in more than one component.

	export const USER = new InjectionToken(
  'user',
  {
    providedIn: 'root',
    factory() {

      const auth = inject(FIREBASE_AUTH);
      const destroy = inject(DestroyRef);

      const user = signal<{
        loading: boolean,
        data: userData | null,
        error: Error | null
      }>({
        loading: true,
        data: null,
        error: null
      });

      // server environment
      if (!auth) {
        user.set({
          data: null,
          loading: false,
          error: null
        });
        return user;
      }

      // toggle loading
      user.update(_user => ({
        ..._user,
        loading: true
      }));

      const unsubscribe = onIdTokenChanged(auth,
        (_user: User | null) => {

          if (!_user) {
            user.set({
              data: null,
              loading: false,
              error: 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
          user.set({
            data,
            loading: false,
            error: null
          });
        }, (error) => {

          // handle error
          user.set({
            data: null,
            loading: false,
            error
          });

        });

      destroy.onDestroy(unsubscribe);

      return user;
    }
  }
);

We create and return a signal with the loading data; it updates every time the user state changes. I also handle error cases. Notice we use a DestroyRef to unsubscribe. ALWAYS HANDLE UNSUBSCRIBE! Injection Tokens are pure magic. No other framework can do this out of the box!

Inject#

Notice we can inject the FIREBASE_AUTH or the USER token anywhere we like and use it. So much better than putting it into the constructor. Beautiful!

	const auth = inject(FIREBASE_AUTH);

Login and Logout#

The login and logout functions can be used anywhere in the same manner.

	export const LOGIN = new InjectionToken(
  'LOGIN',
  {
    providedIn: 'root',
    factory() {
      const auth = inject(FIREBASE_AUTH);
      return () => {
        if (auth) {
          signInWithPopup(
            auth,
            new GoogleAuthProvider()
          );
          return;
        }
        throw 'Can\\\\'t run Auth on Server';
      };
    }
  }
);

export const LOGOUT = new InjectionToken(
  'LOGOUT',
  {
    providedIn: 'root',
    factory() {
      const auth = inject(FIREBASE_AUTH);
      return () => {
        if (auth) {
          signOut(auth);
          return;
        }
        throw 'Can\\\\'t run Auth on Server';
      };
    }
  }
);

Todo Service#

The Todo service is similar, except that it depends on the user service. You can’t subscribe to a user’s collection unless the user is logged in.

	export const TODOS = new InjectionToken(
  'TODOS',
  {
    providedIn: 'root',
    factory() {
      const db = inject(Firestore);
      const user = inject(USER);

      const todos = signal<{
        data: TodoItem[],
        loading: boolean,
        error: FirebaseError | null
      }>({
        data: [],
        loading: true,
        error: null
      });

      effect((onCleanup) => {

        const userData = user().data;

        if (!userData) {
          untracked(() => {
            todos.set({
              loading: false,
              data: [],
              error: null
            });
          });
          return;
        }

        const unsubscribe = onSnapshot(

          // query realtime todo list
          query(
            collection(db, 'todos'),
            where('uid', '==', userData.uid),
            orderBy('createdAt')
          ), (q) => {

            // get data, map to todo type
            const data = snapToData(q);

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

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

            // add to store            
            todos.set({
              data,
              loading: false,
              error: null
            });
          }, (error) => {

            // handle errors
            todos.set({
              loading: false,
              data: [],
              error
            });
            
          });

        onCleanup(unsubscribe);
      });

      return todos;
    }
  }
);

The onSnapshot will not get subscribed to unless there is a user, and it will be automatically unsubscribed when a user logs out. Again, we handle unsubscribing with DestroyRef.

Snapshot Data#

This version does not use Data Converters, but you could equally build one. Here we just handle the data from the collection manually. We also need to handle the Dates and Timestamps.

	export const snapToData = (
  q: QuerySnapshot<DocumentData, DocumentData>
) => {

  // creates todo data from snapshot
  if (q.empty) {
    return [];
  }
  return q.docs.map((doc) => {
    const data = doc.data({
      serverTimestamps: 'estimate'
    });
    const createdAt = data['createdAt'] as Timestamp;
    return {
      ...data,
      createdAt: createdAt.toDate(),
      id: doc.id
    }
  }) as TodoItem[];
}

Add, Delete, and Update#

The CRUD operations work as expected too. Signals get the data in the factory functions.

	export const ADD_TODO = new InjectionToken(
  'ADD_TODO',
  {
    providedIn: 'root',
    factory() {

      const user = inject(USER);
      const db = inject(Firestore)

      return (e: SubmitEvent) => {

        e.preventDefault();

        const userData = user().data;

        if (!userData) {
          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(db, 'todos'), {
          uid: userData.uid,
          text: task,
          complete: false,
          createdAt: serverTimestamp()
        });
      };
    }
  }
);

export const UPDATE_TODO = new InjectionToken(
  'UPDATE_TODO',
  {
    providedIn: 'root',
    factory() {
      const db = inject(Firestore);
      return (id: string, complete: boolean) => {
        updateDoc(doc(db, 'todos', id), { complete });
      };
    }
  }
);

export const DELETE_TODO = new InjectionToken(
  'DELETE_TODO',
  {
    providedIn: 'root',
    factory() {
      const db = inject(Firestore);
      return (id: string) => {
        deleteDoc(doc(db, 'todos', id));
      };
    }
  }
);

It would be impossible to do this cleanly in any Framework except Angular (and Analog, of course).

Todo Component#

Showing the todos is much better than using the ngIf directive.

	<div>
    @if (!todos().loading) {
    <div class="grid grid-cols-[auto,auto,auto,auto] gap-3 justify-items-start">
        @for (todo of todos().data; track todo.id) {
        <app-todo-item class="contents" [todo]="todo" />
        } @empty {
        <p><b>Add your first todo item!</b></p>
        }
    </div>
    <app-todo-form />
    } @else {
    <p>Loading...</p>
    }
</div>

We can load and track the data easily without creating unnecessary templates.

About Page#

We also need to get data server-side only. We do this with a resolver.

Resolver Utilities#

I’m hoping to get these utility functions implemented into Analog.

	export const useAsyncTransferState = async <T>(
    name: string,
    fn: () => T
) => {
    const state = inject(TransferState);
    const key = makeStateKey<T>(name);
    const cache = state.get(key, null);
    if (cache) {
        return cache;
    }
    const data = await fn() as T;
    state.set(key, data);
    return data;
};

export const useTransferState = <T>(
    name: string,
    fn: () => T
) => {
    const state = inject(TransferState);
    const key = makeStateKey<T>(name);
    const cache = state.get(key, null);
    if (cache) {
        return cache;
    }
    const data = fn() as T;
    state.set(key, data);
    return data;
};

export const injectResolver = <T>(name: string) =>
    inject(ActivatedRoute).data.pipe<T>(map(r => r[name]));

export const injectSnapResolver = <T>(name: string) =>
    inject(ActivatedRoute).snapshot.data[name] as T;

About Resolver#

The about resolver uses the useAsyncTransferState utility function to automatically grab the function on the server and and hydrate the data to the browser. This is done automatically in a component but not in a resolver.

	import { inject, isDevMode } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { Firestore, doc, getDoc } from '@angular/fire/firestore';
import { useAsyncTransferState } from '@lib/utils';

export type AboutDoc = {
  name: string;
  description: string;
};

export const aboutResolver: ResolveFn<AboutDoc> = async () =>

  useAsyncTransferState('about', async () => {

    const db = inject(Firestore);

    const aboutSnap = await getDoc(
      doc(db, '/about/ZlNJrKd6LcATycPRmBPA')
    );

    if (!aboutSnap.exists()) {
      throw 'Document does not exist!';
    }

    const data = aboutSnap.data() as AboutDoc;

    if (isDevMode()) {
      console.log(data);
    }

    return data;

  });

It returns a resolved promise before the component is rendered.

About Component#

We can easily inject the resolver into our component using the utility resolver function.

	about = injectResolver<AboutDoc>('data');

This is the async version which requires a pipe. AsyncPipe must also be imported. However, if we don’t expect our data to change in the component, we could use the snapshot version, which doesn’t require an observable and AsyncPipe.

	about = injectSnapResolver<AboutDoc>('data');

The rest of the data is shown as expected.

	import { Component } from '@angular/core';
import { AboutDoc } from './about.resolver';
import { injectResolver } from '@lib/utils';
import { AsyncPipe } from '@angular/common';

@Component({
    selector: 'app-about',
    standalone: true,
    imports: [AsyncPipe],
    template: `
    @if (about | async; as data) {
    <div class="flex items-center justify-center my-5">
        <div class="border w-[400px] p-5 flex flex-col gap-3">
            <h1 class="text-3xl font-semibold">{{ data.name }}</h1>
            <p>{{ data.description }}</p>
        </div>
    </div>
    }
    `
})
export default class AboutComponent {
    about = injectResolver<AboutDoc>('data');
}

Importing components from the server to the browser in Angular has never been so easy!

Building an app in Angular has become fun again! No more worrying about Zone.js problems or complex observables.

🎩 Signals with Analog are awesome!

Repo: GitHub

Demo: Netlify Edge

J


Related Posts

© 2024 Code.Build