Login

Qwik City Todo App with Firebase

J
Jonathan Gamble
Published Updated 18 min read


TL;DR#

A Qwik City todo app with Google sign-in, live Firestore todos, and a server-loaded About page.

Qwik Firebase Todo App

Firebase Setup#

.env#

Add your Firebase web app configuration as a JSON string in the .env file:

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

app.d.ts#

Defines the shared user, todo, and About document types used across the app.

TypeScript
export { };

declare global {

  type UserType = {
    displayName: string | null
    photoURL: string | null;
    uid: string;
    email: string | null;
  };

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

  type TodoDoc = {
    id: string;
    uid: string;
    text: string;
    complete: boolean;
    createdAt: Date;
  };

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

}

lib/firebase.ts#

Initializes the browser Firebase app and exports its Auth and Firestore clients.

TypeScript
import { getApp, getApps, initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';

// import your .env variable
// PUBLIC_FIREBASE_CONFIG={YOUR FIREBASE CONFIG}
// make sure the Firebase keys are in Quotes ""
const firebase_config = JSON.parse(
    import.meta.env.PUBLIC_FIREBASE_CONFIG
);

// initialize firebase
export const app = getApps().length
    ? getApp()
    : initializeApp(firebase_config);

export const auth = getAuth(app);
export const db = getFirestore(app);

lib/firebase-lite.ts#

Creates the Firestore Lite client used by the server-loaded About page.

TypeScript
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore/lite";

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

const serverApp = initializeApp(firebase_config);

// !!! This is imported from `firestore/lite` directory for smaller server imports
export const serverDB = getFirestore(serverApp);

Authentication#

lib/auth.ts#

Handles Google sign-in, sign-out, and shared user state.

TypeScript
import {
    createContextId,
    useContext,
    useContextProvider,
    useStore,
    useVisibleTask$
} from '@builder.io/qwik';
import {
    GoogleAuthProvider,
    onIdTokenChanged,
    signInWithPopup,
    signOut,
    type User
} from 'firebase/auth';
import { FirebaseError } from 'firebase/app';
import { auth } from './firebase';

// User context key
const USER_CONTEXT = createContextId<{ value: UserState }>('user');

export const loginWithGoogle = async () => {
    try {
        await signInWithPopup(auth, new GoogleAuthProvider());
        return { error: null };
    } catch (error) {
        if (error instanceof FirebaseError) {
            return { error: error.message };
        }
        throw error;
    }
};

export const logout = async () => {
    try {
        await signOut(auth);
        return { error: null };
    } catch (error) {
        if (error instanceof FirebaseError) {
            return { error: error.message };
        }
        throw error;
    }
};

export const setUser = () => {

    const user = useStore<{ value: UserState }>({
        value: {
            loading: true,
            data: null
        }
    });

    // Share the same user state with child components
    useContextProvider(USER_CONTEXT, user);

    // Create user listener
    useVisibleTask$(({ cleanup }) => {

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

            // Not logged in
            if (!_user) {
                user.value = {
                    loading: false,
                    data: null
                };
                return;
            }

            // Logged in
            const { displayName, photoURL, uid, email } = _user;

            user.value = {
                loading: false,
                data: { displayName, photoURL, uid, email }
            };
        });

        // Remove the listener when the layout is destroyed
        cleanup(unsubscribe);
    });

    return user;
};

export const getUser = () => {
    return useContext(USER_CONTEXT);
};

Todos#

lib/todos.ts#

Subscribes to todos for the current user and provides create, update, and delete functions.

TypeScript
import { useStore, useVisibleTask$ } from '@builder.io/qwik';
import {
    collection,
    deleteDoc,
    doc,
    FirestoreError,
    onSnapshot,
    orderBy,
    query,
    serverTimestamp,
    setDoc,
    Timestamp,
    type FirestoreDataConverter,
    updateDoc,
    where
} from 'firebase/firestore';
import { getUser } from './auth';
import { auth, db } from './firebase';

// Only used to create example texts -- DO NOT USE IN PRODUCTION
export const generateText = () =>
    doc(collection(db, 'todos'))
        .id
        .substring(0, 10)
        .toLowerCase();

const todoConverter: FirestoreDataConverter<TodoDoc> = {
    toFirestore(todo) {
        return todo;
    },

    fromFirestore(snapshot): TodoDoc {

        // Use an estimated date for pending server timestamps
        const data = snapshot.data({
            serverTimestamps: 'estimate'
        });

        // Convert the Firestore timestamp to a Date
        const createdAt = data.createdAt as Timestamp;

        return {
            id: snapshot.id,
            uid: data.uid,
            text: data.text,
            complete: data.complete,
            createdAt: createdAt.toDate()
        };
    }
};

export const useTodos = () => {

    const user = getUser();

    const todos = useStore<{
        value: {
            data: TodoDoc[];
            loading: boolean;
            error: string | null;
        };
    }>({
        value: {
            data: [],
            loading: true,
            error: null
        }
    });

    useVisibleTask$(({ track, cleanup }) => {

        // Reconnect when the logged-in user changes
        const currentUser = track(() => user.value.data);

        // Must be logged in
        if (!currentUser) {
            todos.value = {
                data: [],
                loading: false,
                error: null
            };
            return;
        }

        todos.value = {
            data: [],
            loading: true,
            error: null
        };

        // Subscribe to this user's todos
        const unsubscribe = onSnapshot(
            query(
                collection(db, 'todos'),
                where('uid', '==', currentUser.uid),
                orderBy('createdAt')
            ).withConverter(todoConverter),
            (snapshot) => {

                const data = snapshot.docs.map((item) => item.data());

                if (import.meta.env.DEV) {
                    console.log(data);
                }

                todos.value = {
                    data,
                    loading: false,
                    error: null
                };
            },
            (error) => {
                todos.value = {
                    data: [],
                    loading: false,
                    error: error.message
                };
            }
        );

        // Remove the previous listener when the user changes
        cleanup(unsubscribe);
    });

    return todos;
};

export const addTodo = async (text: string) => {

    const user = auth.currentUser;

    if (!user) {
        return { error: 'No user' };
    }

    try {
        await setDoc(
            doc(collection(db, 'todos')),
            {
                uid: user.uid,
                text,
                complete: false,
                createdAt: serverTimestamp()
            }
        );

        return { error: null };
    } catch (error) {
        if (error instanceof FirestoreError) {
            return { error: error.message };
        }

        throw error;
    }
};

export const updateTodo = async (id: string, newStatus: boolean) => {

    try {
        await updateDoc(
            doc(db, 'todos', id),
            {
                complete: newStatus,
                updatedAt: serverTimestamp()
            }
        );

        return { error: null };
    } catch (error) {
        if (error instanceof FirestoreError) {
            return { error: error.message };
        }

        throw error;
    }
};

export const deleteTodo = async (id: string) => {

    try {
        await deleteDoc(
            doc(db, 'todos', id)
        );

        return { error: null };
    } catch (error) {
        if (error instanceof FirestoreError) {
            return { error: error.message };
        }

        throw error;
    }
};

Server-side Firestore#

lib/about.ts#

Fetches the About document and validates its fields with Valibot.

TSX
import { doc, getDoc } from "firebase/firestore/lite";

import * as v from "valibot";
import { serverDB } from "./firebase-lite";

// Valibot is smaller and faster than Zod, use Valibot
const AboutDocSchema = v.object({
    name: v.string(),
    description: v.string()
});

export const getAbout = async () => {

    const aboutSnap = await getDoc(doc(serverDB, "/about/ZlNJrKd6LcATycPRmBPA"));

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

    // Verifiy document with Valibot
    // Only necessary for doubts on doc integrity
    const result = v.safeParse(AboutDocSchema, aboutSnap.data());

    if (!result.success) {
        throw "Malformed About document";
    }

    return result.output;
};

components/home.tsx#

Shows a loading message, sign-in button, or profile based on authentication state.

TSX
import { component$ } from "@builder.io/qwik";
import { setUser } from "~/lib/auth";
import Profile from "./profile";
import { Loading, Login } from "./helpers";

export default component$(() => {

  const user = setUser();

  return (
    <div class="text-center">
      <h1 class="text-3xl font-semibold my-3">Qwik Firebase Todo App</h1>
      {user.value.loading ? <Loading /> : user.value.data ? <Profile /> : <Login />}
    </div>
  );
});

components/helpers.tsx#

Provides the loading, sign-in, and sign-out controls.

TSX
import { loginWithGoogle, logout } from "~/lib/auth";

export const Loading = () => {
    return <p>Loading...</p>;
};

export const Login = () => {
    return <button type="button" class="border p-2 rounded-md text-white bg-red-600" onClick$={() => loginWithGoogle()}>
        Signin with Google
    </button>
};

export const Logout = () => {
    return <p>
        <button type="button" class="border p-2 rounded-md text-white bg-lime-600" onClick$={() => logout()}>
            Logout
        </button>
    </p>;
};

components/profile.tsx#

Displays the signed-in user profile and todo list.

TSX
import { component$ } from '@builder.io/qwik';
import { getUser } from '~/lib/auth';
import Todos from './todos';
import { Logout } from './helpers';

export default component$(() => {

    const user = getUser();

    if (!user.value.data) {
        return;
    }

    const { displayName, photoURL, uid } = user.value.data;

    return (
        <div class="flex flex-col gap-3 items-center">
            <h3 class="font-bold">Hi {displayName}!</h3>
            <img src={photoURL || ''} width="100" height="100" alt="user avatar" />
            <p>Your userID is {uid}</p>
            <Logout />
            <Todos />
        </div>
    );
});

components/todos.tsx#

Renders the live todo list and the form for adding a task.

TSX
import { $, component$, useSignal } from "@builder.io/qwik";
import { useTodos, addTodo, generateText } from "~/lib/todos";
import { Todo } from "./todo-item";

// todo component
export default component$(() => {

    const todos = useTodos();

    return (
        <div>
            <div class="flex flex-col gap-3">
                {todos.value.loading ? (
                    <p>Loading todos...</p>
                ) : todos.value.error ? (
                    <p role="alert" class="text-red-600">
                        {todos.value.error}
                    </p>
                ) : todos.value.data.length ? (
                    todos.value.data.map((todo) => <Todo key={todo.id} todo={todo} />)
                ) : (
                    <p><b>Add your first todo item!</b></p>
                )}
            </div>
            <TodoForm />
        </div>
    );
});

// todo form
export const TodoForm = component$(() => {
    const text = useSignal(generateText());
    const error = useSignal<string | null>(null);

    const onSubmit = $(async () => {
        error.value = null;

        const submittedText = text.value;

        const result = await addTodo(submittedText);
        error.value = result.error;

        if (!result.error) {
            text.value = generateText();
        }
    });

    return (
        <div class="mt-5">
            <form
                class="flex gap-3 items-center justify-center"
                preventdefault:submit
                onSubmit$={onSubmit}
            >
                <input
                    class="border p-2 rounded-lg"
                    bind:value={text}
                />
                <button
                    class="border p-2 rounded-lg bg-purple-600 text-white font-semibold"
                    type="submit"
                >
                    Add Task
                </button>
            </form>
            {error.value && (
                <p role="alert" class="mt-2 text-center text-red-600">
                    {error.value}
                </p>
            )}
        </div>
    );
});

components/todo-item.tsx#

Displays one todo with controls to toggle or delete it.

TSX
import { component$, useSignal } from "@builder.io/qwik";
import { deleteTodo, updateTodo } from "~/lib/todos";

// each todo item
export const Todo = component$(({ todo }: { todo: TodoDoc }) => {
    const error = useSignal<string | null>(null);

    return (
        <div class="grid grid-cols-[auto,auto,auto,auto] gap-3 items-center justify-items-start">
            <span class={todo.complete ? 'line-through text-green-700' : ''}>{todo.text}</span>
            <span class={todo.complete ? 'line-through text-green-700' : ''}>{todo.id}</span>
            <button type="button" onClick$={async () => {
                const result = await updateTodo(todo.id, !todo.complete);
                error.value = result.error;
            }}>{todo.complete ? '✔️' : '❌'}</button>
            <button type="button" onClick$={async () => {
                const result = await deleteTodo(todo.id);
                error.value = result.error;
            }}> 🗑 </button>
            {error.value && <p role="alert" class="col-span-4 text-red-600">{error.value}</p>}
        </div>
    );
});

App Structure#

routes/index.tsx#

Renders the home page and sets its document metadata.

TSX
import type { DocumentHead } from '@builder.io/qwik-city';
import Home from '~/components/home';

export default () => <Home />;

export const head: DocumentHead = {
  title: 'Qwik Firebase Todo App',
  meta: [
    {
      name: 'description',
      content: 'Qwik Firebase Todo App'
    },
  ],
};

routes/about/index.tsx#

Loads and renders the About document through a route loader.

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

export const useAboutPage = routeLoader$(async () => {
    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>
    );
});

routes/layout.tsx#

Wraps each route with navigation links.

TSX
import { component$, Slot } from '@builder.io/qwik';
import { Link } from '@builder.io/qwik-city';

export default component$(() => {
  return (
    <>
      <Slot />
      <nav class="flex gap-3 justify-center mt-5">
        <Link href="/">Home</Link>
        <Link href="/about">About</Link>
      </nav>
    </>
  );
});

J

Comments

Share a thought or join the conversation.

Sign In to comment or reply.

Newsletter

Get updates on future FREE tutorials, paid courses, and new blog posts!

Free to join. Unsubscribe anytime.

Tags

Explore posts by topic.