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

.env#

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

Dotenv
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.

TSX
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.

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

lib/auth.ts#

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

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

lib/todos.ts#

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

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

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

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!

Tags

Explore posts by topic.