Home
> Qwik Todo App with Firebase

Qwik Todo App with Firebase

20 min read

Jonathan Gamble

jdgamble555 on Sunday, January 15, 2023 (last modified on Wednesday, April 10, 2024)

Qwik has changed a lot since I originally wrote this article. I decided to update my Qwik App to the latest version, and I looked at my code. My app broke, and I noticed my coding principles have changed. Your coding style and knowledge changes a lot in a year, and I now try and follow more clean code rules, specifically Single Responsibility Principle, Don’t Repeat Yourself, and Guard Clauses. This makes my code incredibly efficient, and easier to read.

Qwik Firebase Todo App

TL;DR#

Qwik is meant to run on the server and browser. When you use Firebase on the server, you need firebase-admin to handle authentication. Qwik is made to be deployed to the edge where firebase-admin will not work. However, you can still generate server content from Firestore as long as you don’t need to be logged in to do it. The work around is to handle Firebase Authentication in Cloud Functions. I will handle that in another post. This posts shows you the Gotchas and Setup for Firebase and Qwik on Edge functions.

Firebase#

When Firebase first became popular, client side apps, or single page apps (SPA) were the thing. As we have learned, we need the server for security, seo, and speed. I am deploying my test app to Vercel Edge, as Qwik does not have a Vercel Node deployment option; this is fine with me. I hope Serverless dies and the Edge becomes more powerful. Cold starts are terrible.

The problem is firebase-admin, which is necessary for serverless, is not supported on Edge Functions (Deno may be the exception, but I have not seen a working example). The work-around for this, will be to call firebase functions from your edge deployment. This means your app will ultimately be at the mercy of a serverless function. However, I have some thoughts to mitigate this, and I will build a test app for my next article.

Server Loading#

If things just worked, I wouldn’t have to build a test app and write about it. When your app hydrates, or resumes in the case of Qwik, certain imports will not work on the server. We must find a way to work around this. ⚠️ You won't notice this until to deploy, so make sure to test deployment as you build.

/lib/firebase.ts#

First we create a reusable file for our firebase initialization. We are only using one instance of firebase, so we don’t need to over complicate our initialization. We can then just parse our JSON config instead of manually configuring each variable.

	import { getApps, initializeApp } from 'firebase/app';
import { getAuth, signInWithPopup } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { isBrowser } from '@builder.io/qwik/build';

const firebase_config = JSON.parse(
  import.meta.env.PUBLIC_FIREBASE_CONFIG
);

// initialize firebase

if (!getApps().length) {
    initializeApp(firebase_config);
}

export const db = getFirestore();

// load auth on browser only
export const auth = isBrowser ? getAuth() : null;
export const signIn = isBrowser ? signInWithPopup : null;

Browser Only#

Normally we can just import our signInWithPopup, but we don’t want it to cause a server error. getAuth will also cause server issues. Generally speaking, Firebase Auth and Firebase Analytics do not work well on the server, so you must account for that.

	export const auth = isBrowser ? getAuth() : null;
export const signIn = isBrowser ? signInWithPopup : null;

.env#

We must add our Firebase config information to the .env file. It should look like this:

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

Notice we have to add our keys in quotes, which is not how our config information is by default.

/lib/user.ts#

Whenever we load our login and logout methods, we have to make sure the auth is present and not null. Same goes four our sign in method.

	import { auth, signIn } from './firebase';
...

export const loginWithGoogle = $(() => {
    if (signIn && auth) {
        signIn(auth, new GoogleAuthProvider());
    }
});

export const logout = $(() => {
    if (auth) {
        signOut(auth);
    }
});

Note: Technically you could also dynamically import the methods that cause problems, but I find the isBrowser method to be cleaner when you have to share it across you code. It is also always imported on the client by default.

	// only for reference
const firebaseAuth = await import('firebase/auth');
const auth = firebaseAuth.getAuth();
firebaseAuth.signInWithPopup(auth, new GoogleAuthProvider());

useVisibleTask$#

While most of the time you want to use useTask$, in this case you want to only run your code on the client. Firestore itself has no problems running on the client, but unless you have an auth token, it can’t know the logged in user.

	export function _useUser() {

    const _store = useStore<{
        loading: boolean,
        data: userData | null
    }>({
        loading: true,
        data: null
    });

    useVisibleTask$(() => {

        // toggle loading
        _store.loading = true;

        // server environment
        if (!auth) {
            _store.loading = false;
            _store.data = null;
            return;
        }

        // subscribe to user changes
        return onIdTokenChanged(auth, (_user: User | null) => {

            _store.loading = false;

            if (!_user) {
                _store.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 (import.meta.env.DEV) {
                console.log(data);
            }

            // set store
            _store.data = data;
        });

    });

    return _store;
};

The future is custom hooks in almost all frameworks. We are returning onIdTokenChanged. This ensures it gets automatically unsubscribed.

	// YOU DON'T NEED TO DO THIS
const unsubscribe = onIdTokenChanged(auth, .... {
...
});

return () => unsubscribe();

// YOU DON'T NEED TO DO THIS EITHER
useVisibleTask$(({ cleanup }) => {

    const unsubscribe = onIdTokenChanged(auth, .... {
    ...
    });
    
  cleanup(unsubscribe);
});

We have to unsubscribe to onIdTokenChanged to prevent data leaks. Some people add extraneous code to do this when it is unnecessary. useVisibleTask$ will handle all of this for us by simply returning the subscription.

Notice we use onIdTokenChanged to subscribe to changes. I use onIdTokenChanged always instead of onAuthStateChanged just in case there is a token change or custom claims update. Basically it just captures everything.

Here is the key code that prevents problems:

	// server environment
if (!auth) {
    _store.loading = false;
    _store.data = null;
    return;
}

We don’t want to run any of the code if there is not auth. This solves our server problem. Anywhere we import auth, we need to account for this. I am using guard clauses (checking for the negative first) instead of if / else. This is best for readability.

useShared#

Since we are importing useUser in multiple places, we don't want to keep subscribing to the user changes. We only want to run this once. The proper way to do this in Qwik is to use a context. I created a reusable context hook to make things easy for all Qwik apps.

	import {
    createContextId,
    useContext,
    useContextProvider
} from "@builder.io/qwik";

export const sharedContext = <T>(name: string) =>
    createContextId<T>('io.builder.qwik.' + name);

export const getShared = <T extends object>(name: string) =>
    useContext<T, null>(sharedContext(name), null);

export const createShared = <T extends object>(
  name: string, 
  content: T
) => useContextProvider<T>(sharedContext(name), content);

export const useShared = <T extends object>(
    hook: () => T,
    name: string
) => {

    // get context if exists
    const shared = getShared<T>(name);
    if (shared) {
        return shared;
    }

    // return new shared context
    const _shared = hook();
    createShared(name, _shared);
    return _shared;
};

Now we can take any custom hook we want, and share it in multiple components; it will only run once.

	export const useUser = () => useShared(_useUser, 'user');

We just have to pass the hook, and name the context. Never again worry about shared variables!

/lib/todos.ts#

We also need to create a store for todos. Remember, we can’t fetch todos without a logged in user. Our where filter depends on the user id being present.

	export function useTodos() {

    const user = useUser();

    const _store = useStore<{
        todos: TodoItem[],
        loading: boolean
    }>({
        todos: [],
        loading: true
    });

    useVisibleTask$(({ track }) => {

        track(() => user.data);

        _store.loading = true;

        if (!user.data) {
            _store.loading = false;
            _store.todos = [];
            return;
        }

        return onSnapshot(

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

                // toggle loading
                _store.loading = false;

                // 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 (import.meta.env.DEV) {
                    console.log(data);
                }

                // add to store
                _store.todos = data;
            });
    });

    return _store;
};

Before we load our realtime subscription, we must track our user data changes, and of course use our Guard Clause if there is no user.

	useVisibleTask$(({ track }) => {

    track(() => user.data);

    _store.loading = true;

    if (!user.data) {
        _store.loading = false;
        _store.todos = [];
        return;
    }
...

We will also need to convert our results to a todo item, when there is results.

	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 created = data.created as Timestamp;
        return {
            id: doc.id,
            text: data.text,
            complete: data.complete,
            created: created.toDate(),
            uid: data.uid
        };
    }) as TodoItem[];
}

Finally we serialize all fields, just like it would be on the server. While I don’t show dates in the app, created is ready to be used for sorting. ⚠️ Be sure to properly set all fields in the Qwik app, as you don't want to add a field that is not serialized correctly.

Firebase Admin#

This app won’t work without authentication, which we have to have run on the client. We can still get our seo needs fulfilled if we wanted to by running Firestore on the server without auth. We just need to make sure our Firestore Rules match, which should be there anyway. The workaround would be to create a Cloud Function to handle our authentication token and cookie information.

Server Hydration#

You can still query server information, as long as you don't need authentication, on the server from the Edge. All you do is run a Firebase query as expected. In Qwik, you want to use the routeLoader$ for this. I made a sample About page to show how this can be done.

/lib/about.ts#

To get the date, you just create a fetch function.

	import { doc, getDoc } from "firebase/firestore";
import { db } from "./firebase";

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

export const getAbout = async () =>{

    // call whatever document you want here
    const aboutSnap = await getDoc(
      doc(db, '/about/ZlNJrKd6LcATycPRmBPA')
    );

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

And call routeLoader$ in your routes folder.

/routes/about/index.ts#

	import { component$ } from "@builder.io/qwik";
import { routeLoader$ } from "@builder.io/qwik-city";
import { getAbout } from "~/lib/about";

export const useAboutPage = routeLoader$(
  async ({ cacheControl }
) => {

    cacheControl({ maxAge: 31536000, public: true });

    return await getAbout();
});

export default component$(() => {

    const about = useAboutPage();

    return (
        <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">{about.value.name}</h1>
                <p>{about.value.description}</p>
            </div>
        </div>
    );
});

About Example Screenshot#

Qwik Server Hydration Example

Example App#

Here is the full working todo app. Everything else in my app is standard Qwik of Firebase stuff. I also updated it to use Tailwind and look a little more cleaner.

Old Version Screenshot#

Old Firebase Todo App

Repo: GitHub

Firebase Libraries
About Code

Demo: Vercel Edge

J for


Related Posts

Newsletter

Get updates and the latest coding blog posts!

© 2024 Code.Build