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 likeQwik
andVue
. - We handle the
error
andloading
states in the signal. - ALWAYS
unsubscribe
. Here, we useonDestroy
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 theupdatedAt
date by adding the data converter to theupdateTodo
function. See Data Converters. - The
todos
collection subscription is dependent on theuser
. 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 ourtodos
signal inside theuntrack()
function. - We don’t need to use
untrack()
inside theonSnapshot
because it is anasync
function. This means the signal is not tracked. - When we get our Firebase data with a date type, we need to use
estimate
withserverTimestamps
. 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