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

Svelte 5 Todo App with Firebase

18 min read

Jonathan Gamble

jdgamble555 on Wednesday, May 8, 2024 (last modified on Wednesday, May 8, 2024)

Svelte 5 is coming very soon since it has hit the Release Candidate stage. Svelte 5 now has “runes,” Svelte’s version of signals. These are amazing, and I don’t want to go back. Translating my Todo App was extremely easy, as I have built it many times with signals.

TL;DR#

Using a Svelte rune outside of a component requires getters and setters. Once you set up your signals correctly, everything works extremely similarly to other Frameworks. $state() works great in a component, but using the Single Responsibility Principle will force smart programmers to create their own sharable runes.

Setup#

The setup is the same as in most other Firebase applications. We export the auth and db handlers.

	// lib/firebase.ts

import { PUBLIC_FIREBASE_CONFIG } from '$env/static/public';
import { getApp, 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
export const app = getApps().length
    ? getApp()
    : initializeApp(firebase_config);

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

Types#

Add the types to the global namespace in app.d.ts.

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

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

Runes#

I’m not sure why Svelte was built like this, as it is a compiler, but you can’t export $state variables from component to component. I made a cheat version similar to Vue and Qwik's signals.

	// lib/rune.svelte.ts

export const rune = <T>(initialValue: T) => {

    let _rune = $state(initialValue);

    return {
        get value() {
            return _rune;
        },
        set value(v: T) {
            _rune = v;
        }
    };
};

Notice that you must use the .svelte.ts file type to work correctly with the $state. Again, this could have been the perfect reason to allow $state to work everywhere, but we are stuck making “getters and setters” manually. I don’t want to do it everywhere, so I made a reusable template function.

Shared#

The runes and custom runes need to be sharable everywhere, so I reused my shared contexts to work with signals (runes) and stores.

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

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

// writable store context
export const useWritable = <T>(name: string, value?: T) =>
    useSharedStore(name, writable, value);

// readable store context
export const useReadable = <T>(name: string, value: T) =>
    useSharedStore(name, readable, value);

// shared rune
export const useRune = <T>(name: string, value: T) =>
    useSharedStore(name, rune, value);

User Hook#

We need to get the user from onIdTokenStateChanged and share it across our app. We must be careful not to call it more than once, so we use our useShared hook above.

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

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

export const logout = async () =>
    await signOut(auth);

const _useUser = () => {

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

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

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

            // logged in
            const { displayName, photoURL, uid, email } = _user;
            user.value = {
                loading: false,
                data: { displayName, photoURL, uid, email },
                error: null
            };
        }, (error) => {

            // error
            user.value = {
                loading: false,
                data: null,
                error
            };
        });

    onDestroy(unsubscribe);

    return user;
};

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

📝Notes#

  • I import the rune function and call it exactly like Qwik and Vue.
  • We handle the error and loading states in the signal.
  • ALWAYS unsubscribe. Here, we use onDestroy as we would in a component.

Todos Hook#

We need a hook to get our todos and subscribe to the changes.

	import {
    collection,
    deleteDoc,
    doc,
    onSnapshot,
    orderBy,
    query,
    serverTimestamp,
    setDoc,
    where,
    QueryDocumentSnapshot,
    type SnapshotOptions,
    Timestamp,
    type PartialWithFieldValue,
    type SetOptions
} from "firebase/firestore";
import { auth, db } from "./firebase";
import { useUser } from "./user.svelte";
import { FirebaseError } from "firebase/app";
import { untrack } from "svelte";
import { rune } from "./rune.svelte";
import { dev } from "$app/environment";

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

const todoConverter = {
    toFirestore(
        value: PartialWithFieldValue<Todo>,
        options?: SetOptions
    ) {
        const isMerge = options && 'merge' in options;
        if (!auth.currentUser) {
            throw 'User not logged in!';
        }
        return {
            ...value,
            uid: auth.currentUser.uid,
            [isMerge
                ? 'updatedAt'
                : 'createdAt'
            ]: serverTimestamp()
        };
    },
    fromFirestore(
        snapshot: QueryDocumentSnapshot,
        options: SnapshotOptions
    ) {
        const data = snapshot.data(options);
        const createdAt = data['createdAt'] as Timestamp;
        return {
            ...data,
            id: snapshot.id,
            createdAt: createdAt.toDate()
        } as Todo;
    }
};

export const useTodos = () => {

    const user = useUser();

    const _todos = rune<{
        data: Todo[],
        loading: boolean,
        error: FirebaseError | null
    }>({
        data: [],
        loading: true,
        error: null
    });

    $effect(() => {

        const _user = user.value.data;

        // filtering todos depend on user
        if (!_user) {
            untrack(() => {
                _todos.value = {
                    loading: false,
                    data: [],
                    error: null
                };
            });
            return;
        }

        return onSnapshot(
            query(
                collection(db, 'todos'),
                where('uid', '==', _user.uid),
                orderBy('createdAt')
            ).withConverter<Todo>(todoConverter), (q) => {

                if (q.empty) {
                    _todos.value = {
                        loading: false,
                        data: [],
                        error: null
                    };
                }

                const data = q.docs.map(doc => doc.data({
                    serverTimestamps: 'estimate'
                }));

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

                _todos.value = {
                    loading: false,
                    data,
                    error: null
                };

            }, (error) => {

                // Handle error
                _todos.value = {
                    loading: false,
                    data: [],
                    error
                };
            });
    });
    return _todos;
};

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

    setDoc(doc(collection(db, 'todos'))
        .withConverter(todoConverter), {
        text,
        complete: false
    }).catch((e) => {
        if (e instanceof FirebaseError) {
            console.error(e.code)
        }
    });
}

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

    try {
        await setDoc(
            doc(db, 'todos', id),
            { complete: newStatus },
            { merge: true }
        );
    } catch (e) {
        if (e instanceof FirebaseError) {
            console.error(e.code);
        }
    }
}

export const deleteTodo = (id: string) => {
    deleteDoc(doc(db, 'todos', id));
}

📝 Notes#

  • This hook is only called once in one component, so we don't need useShared.
  • I’m using Data Converters, but they are not necessary.
  • The createdAt date is added, but you could also add the updatedAt date by adding the data converter to the updateTodo function. See Data Converters.
  • The todos collection subscription is dependent on the user. If there is no user, we need to automatically unsubscribe and resubscribe when there is one. The $effect() function handles this.
  • To prevent looping, we generally should not set signals inside $effect(). However, we can get around this by setting our todos signal inside the untrack() function.
  • We don’t need to use untrack() inside the onSnapshot because it is an async function. This means the signal is not tracked.
  • When we get our Firebase data with a date type, we need to use estimate with serverTimestamps. This will keep our optimistic updates working well for the user. See Dates and Timestamps.

User Components#

You can use the user hook in any component, and it will only be called once.

	<script lang="ts">
    import { loginWithGoogle, logout } from '$lib/user.svelte';
    import Todos from '@components/todos.svelte';
    import Profile from '@components/profile.svelte';
    import { useUser } from '$lib/user.svelte';

    const _user = useUser();
    const user = $derived(_user.value);
</script>

<h1 class="my-3 text-3xl font-semibold text-center">Svelte 5 Firebase Todo App</h1>

<section class="flex flex-col items-center gap-3 p-5">
    {#if user.data}
        <Profile />
        <button
            class="p-3 font-semibold text-white bg-blue-600 border rounded-lg w-fit"
            onclick={logout}
        >
            Logout
        </button>
        <hr />
        <Todos />
    {:else if user.loading}
        <p>Loading...</p>
    {:else if user.error}
        <p class="text-red-500">Error: {user.error}</p>
    {:else}
        <button class="p-2 font-semibold text-white bg-red-600" onclick={loginWithGoogle}>
            Signin with Google
        </button>
    {/if}
</section>

Here we handle the user state with data, loading, and error. However, we cannot destructure signals.

	// ⚠️ THIS WILL NOT WORK!
const { value } = useUser();

// ⚠️ NEITHER WILL THIS
const { data, loading, error } = useUser().value;

If we want to have a reactive user value that is just one variable, we need to redefine it as a new signal using $derived. This will work with any long nested value in a signal.

	const user = $derived(_user.value);

Todos Component#

Notice you will see the same setup for the todos component. Use the $derived to shorten your variable after you get the signal.

	<script lang="ts">
    import { fly } from 'svelte/transition';
    import { useTodos } from '$lib/todos.svelte';
    import TodoItem from '@components/todo-item.svelte';
    import TodoForm from './todo-form.svelte';

    const _todos = useTodos();
    const todos = $derived(_todos.value);
</script>

{#if todos.data?.length}
    <div
        class="grid grid-cols-[auto,auto,auto,auto] gap-3 justify-items-start"
        in:fly={{ x: 900, duration: 500 }}
    >
        {#each todos.data || [] as todo (todo.id)}
            <TodoItem {todo} />
        {/each}
    </div>
{:else if todos.loading}
    <p>Loading...</p>
{:else if todos.error}
    <p class="text-red-500">{todos.error}</p>
{:else}
    <p><b>Add your first todo item!</b></p>
{/if}

<TodoForm />

Server Fetching#

Nothing has changed in SvelteKit when it comes to getting data from the server.

About Library#

Remember to use firestore/lite if you want to fetch on an Edge Function.

	// lib/about.ts
import { doc, getDoc, getFirestore } from "firebase/firestore/lite";
import { app } from "./firebase";

const db = getFirestore(app);

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

About Server#

Call the about function.

	// routes/about/+page.server.ts
import { getAbout } from '$lib/about';
import type { PageServerLoad } from './$types';

export const load = (async () => {

    return {
        about: await getAbout()
    };

}) satisfies PageServerLoad;

About Component#

You can display the data with $page or data from the routes directory.

	<script lang="ts">
    import type { PageData } from './$types';

    export let data: PageData;
</script>

<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.about.name}</h1>
        <p>{data.about.description}</p>
    </div>
</div>

Using Runes in Svelte 5 is much easier than Svelte 4 Stores in every scenario.

Repo: GitHub

Demo: Vercel Edge

↪️ See the Svelte 4 Version


Related Posts

© 2024 Code.Build