Home
> SvelteKit Todo App with Firebase

SvelteKit Todo App with Firebase

15 min read

Jonathan Gamble

jdgamble555 on Saturday, March 9, 2024 (last modified on Sunday, March 10, 2024)

Using Firebase in SvelteKit is quite easy, and doesn’t require any libraries like Angular does. I do use a few tricks to make things easy for Svelte apps, and I like to organize things with the Single Responsibility Principle. The final app will be used in many of my examples.

TL;DR#

This version of my Firebase Todo App runs on SvelteKit, uses Tailwind, and is used as the basis for a lot of my demo apps. The key to fixing problems is how you export your auth variable, and making sure you are using it correctly. Don’t overcomplicate your SvelteKit. The usage of the derived store also insures your database connections are using unsubscribe mechanisms correctly when there is no user. I have also create a server only route for the about page. You can use Firebase on Edge Functions as long as you don't need authentication.

lib/firebase.ts#

I like to keep my firebase initializer file very simple. We initialize an app once from our .env file, if there is none, and export the database and auth variables to be used later.

	import { PUBLIC_FIREBASE_CONFIG } from '$env/static/public';
import { getApps, initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';

const firebase_config = JSON.parse(PUBLIC_FIREBASE_CONFIG);

// initialize and login

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

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

Your .env file should contain all the firebase config information. Make sure to put quotes around the keys.

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

Shared Types#

We can create reusable types in SvelteKit by adding them to our app.d.ts file. We will never have to import them in our app, and they will be available everywhere.

	declare global {

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

    type Todo = {
        id: string;
        text: string;
        complete: boolean;
        created: Date;
    };
    
    namespace App {	}
}

Shared Context#

I created a custom shared hook called useShared that I use in all my Svelte apps. This allows me to safely share state on the server and browser. In our case, we will be sharing the user state, although you could easily share the todos state if you needed to.

	import { getContext, hasContext, setContext } from "svelte";
import { readable, writable } from "svelte/store";

export const useSharedStore = <T, A>(
    name: string,
    fn: (value?: A) => T,
    defaultValue?: A,
) => {
    if (hasContext(name)) {
        return getContext<T>(name);
    }
    const _value = fn(defaultValue);
    setContext(name, _value);
    return _value;
};

This just automatically creates a context if there isn’t one, otherwise it returns the context in need.

lib/user.ts#

First we import our shared store and create the login and logout methods. For this app we use Google login.

	import {
    GoogleAuthProvider,
    onIdTokenChanged,
    signInWithPopup,
    signOut,
    type User
} from "firebase/auth";
import { readable, type Subscriber } from "svelte/store";
import { auth } from "./firebase";
import { useSharedStore } from "./use-shared";

export const loginWithGoogle = async () => 
  await signInWithPopup(auth, new GoogleAuthProvider());
  
export const logout = async () => await signOut(auth);

Here we create our shared user hook using svelte stores. When svelte changes to the signal pattern, I will update or create a new post and it will look more like the Angular and Qwik versions.

	const user = (defaultUser: UserType | null = null) => 
  readable<UserType | null>(
    defaultUser,
    (set: Subscriber<UserType | null>) => {
        return onIdTokenChanged(auth, (_user: User | null) => {
            if (!_user) {
                set(null);
                return;
            }
            const { displayName, photoURL, uid, email } = _user;
            set({ displayName, photoURL, uid, email });
        });
    }
);

Here we just subscribe to our user state by calling onIdTokenChanged. If there is no user, the value will be null. It will automatically subscribe and unsubscribe in the readable store, and it will use only the variables we need in our UserType type.

We also need to convert that custom user store to a shared store by using are useShared hook.

	export const useUser = (defaultUser: UserType | null = null) => 
  useSharedStore('user', user, defaultUser);

If there is no value, our store will be null.

lib/todos.ts#

Whenever you subscribe to todos, you must be logged in. You only want to be able to see your todo list, not anyone elses. Since the todos store depends on the user store, we need to unsubscribe when there is no user. This is best handled using a derived store.

	export const useTodos = (
    user: Readable<UserType | null>,
    todos: Todo[] | null = null
) => {

    // filtering todos depend on user
    return derived<Readable<UserType | null>, Todo[] | null>(
        user, ($user, set) => {
            if (!$user) {
                set(null);
                return;
            }

            // set default value
            set(todos);

            return onSnapshot(
                query(
                    collection(db, 'todos'),
                    where('uid', '==', $user.uid),
                    orderBy('created')
                ), (q) => set(snapToData(q)))
        });
};

Here we create a todo readable store that depends on the user store. If there is no user, the todos will be null. We use onSnapshot to subscribe to the realtime data changes in the todos collection so that we have optimistic updates automatically. This means our data gets changed on our screen immediately before the database changes technically take place.

Helper Functions#

	export const genText = () => Math.random()
  .toString(36)
  .substring(2, 15);

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();
        return {
            ...data,
            created: new Date(data['created']?.toMillis()),
            id: doc.id
        }
    }) as Todo[];
}

I added a few helper functions to get the docs from our snapshot without all the meta data. This Todo App version also has a generate text function for generating quick strings for testing.

CRUD Functions#

We finally have our create, update, and delete functions. In this version we pass the id to the addTodo function, although we could get the user id directly using auth.currentUser?.uid, among other methods.

Displaying the User#

Anywhere we want to use the user data, we simply call the useUser() hook. This can only be called in a svelte component directly to work correctly. Then we use it just like a regular store.

	<script lang="ts">
  const user = useUser();
</script>

...

{#if $user)
  <p>Do something</p>
{/if}

Displaying the Todos#

We only want to call todos in a component that is visible to logged in users. We can use both of our stores to do this correctly.

	const user = useUser();
const todos = useTodos(user);

We simply pass our user store as the initializer of our todos store. This allows the derived store under the hood to make correct decisions about the user collection realtime subscription.

Templates#

To properly call the todos in the html, we need to check for an array. We use the length method. Then we loop through the array or loop through an empty array []. Technically you don’t need to add the empty array if you use length, but I find it good practice. Angular has @empty, which is an addition Svelte needs.

	{#if $todos?.length}
...
  {#each $todos || [] as todo}
     // display todos
  {/each}
  
{/if} 

Server Only#

I created a server only path /about. You load the data with +page.server.ts, and display it in your template with +page.svelte with the data exported variable.

	import { getAbout } from '$lib/about';
import type { PageServerLoad } from './$types';

export const load = (async () => {

    return {
        about: await getAbout()
    };

}) satisfies PageServerLoad;

It is very simple. The about.ts function is reused in the Qwik and soon to be NextJS versions as well.

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

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

export const getAbout = async () => {

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

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

    return aboutSnap.data() as AboutDoc;
};

Very easy. Just remember you can't load the Authentication if you deploy to Edge Functions. The work around would be to validate your token in a callable Cloud Function.

Deployment#

Deploying the final app, even on the Edge, is extremely easy with SvelteKit. You can pretty much just attach your GitHub to Netlify or Vercel edge functions, and everything works as expected. Just make sure you do not have the auth components running on the server anywhere. Firebase Authentication does not work well with the Edge.

Demo: Netlify Edge

Repo: GitHub

J


Related Posts

Newsletter

Get updates and the latest coding blog posts!

© 2024 Code.Build