Subtle mistakes to avoid
Many Svelte 5 Runes example show you how to build a counter. While that’s great for learning syntax, real applications have complex, interdependent state that reveals the quirks and gotchas you need to understand.
Let’s build a portfolio app that demonstrates these challenges. We’ll have authentication, account balance with currency, and exchange rates — all reactive and interdependent. This mirrors real-world apps where changing one piece of data cascades through multiple calculations.
Our app has this dependency chain:
When any piece changes, everything downstream should update efficiently without unnecessary reloading. A real-world app could have dozens of accounts each with different currency and decades of balance history and exchange rates to match and the portfolio values include different levels of aggregation based on account category, type, and institutions.
Authentication is straightforward—it’s either a user object, null (signed out), or undefined (loading). Here’s a clean pattern using createSubscriber
:
// user.svelte.ts
import { GoogleAuthProvider, onAuthStateChanged, signInWithRedirect } from 'firebase/auth'
import type { User } from 'firebase/auth'
import { createSubscriber } from 'svelte/reactivity'
import { auth } from './firebase'
export class UserState {
#user: User | null | undefined
#subscribe: VoidFunction
constructor() {
this.#subscribe = createSubscriber((update) =>
onAuthStateChanged(auth, (user) => {
this.#user = user
update()
})
)
}
get current() {
this.#subscribe()
return this.#user
}
async signIn() {
const provider = new GoogleAuthProvider()
await signInWithRedirect(auth, provider)
}
async signOut() {
await auth.signOut()
}
}
export const user = new UserState()
Key insight: createSubscriber
automatically handles starting/stopping the Firebase listener when components mount/unmount. The subscription only activates when .current
is accessed in a reactive context (typically from the root layout to show the auth status).
Account data depends on the authenticated user. We want to combine createSubscriber
for Firebase listening with dependency on the auth state:
// currency.ts
export const Currency = ['CAD', 'EUR', 'GBP', 'USD', 'BTC'] as const
export type Currency = (typeof Currency)[number]
// data.svelte.ts
import { Currency } from './currency'
import type { User } from 'firebase/auth'
import { doc, onSnapshot, setDoc, updateDoc } from 'firebase/firestore'
import { createSubscriber } from 'svelte/reactivity'
import { firestore } from './firebase'
export interface Data {
currency: Currency
balance: number
}
export class DataState {
#data?: Data
#subscribe: VoidFunction
constructor(private readonly user?: User | null) {
this.#subscribe = createSubscriber(update => {
if (!this.user) return // No subscription without authenticated user
const ref = this.getRef()
// return the unsubscribe cleanup function
return onSnapshot(ref, snap => {
if (snap.exists()) {
this.#data = snap.data() as Data
update()
} else {
// set default data for new users
setDoc(ref, { currency: 'USD', balance: 0.00 })
}
})
})
}
private getRef() {
if (!this.user) throw 'no authenticated user'
return doc(firestore, `data/${this.user.uid}`)
}
get current() {
this.#subscribe()
return this.#data
}
async update(value: Partial<Data>) {
const ref = this.getRef()
await updateDoc(ref, value)
}
}
Exchange rates depends on the account data currency. We want to combine createSubscriber
for Firebase listening with dependency on the data state:
// rates.svelte.ts
import { doc, onSnapshot } from 'firebase/firestore'
import { createSubscriber } from 'svelte/reactivity'
import { firestore } from './firebase'
import type { Currency } from './currency'
export type Rates = Record<Currency, number>
export class RatesState {
#rates?: Rates
#subscribe: VoidFunction
constructor(private readonly currency?: Currency) {
this.#subscribe = createSubscriber(update => {
if (!this.currency) return // No subscription without a currency
const ref = this.getRef()
return onSnapshot(ref, snap => {
if (snap.exists()) {
this.#rates = snap.data() as Rates
update()
}
})
})
}
private getRef() {
if (!this.currency) throw 'no currency'
return doc(firestore, `rate/${this.currency}`)
}
get current() {
this.#subscribe()
return this.#rates
}
}
Key insight: The constructor takes the dependency as a parameter, and createSubscriber
conditionally creates subscriptions. This keeps start/stop logic clean and automatic.
Now we need to connect the account data to depend on the authentication state, and the exchange rates to depend on the account currency, and portfolio calculations that depend on both:
// portfolio.svelte.ts
import { Currency } from './currency'
import { DataState } from './data.svelte'
import { RatesState } from './rates.svelte'
import { user } from './user.svelte'
export class Portfolio {
#data: DataState
#rates: RatesState
#balances?: Record<Currency, number>
constructor() {
// Each $derived creates a new instance when dependencies change
this.#data = $derived(new DataState(user.current))
this.#rates = $derived(new RatesState(this.#data.current?.currency))
this.#balances = $derived(this.calculateBalances(
this.#data.current,
this.#rates.current
))
}
private calculateBalances(data?: Data, rates?: Record<string, number>) {
if (!data || !rates) return undefined
return Currency.reduce((balances, currency) => {
balances[currency] = data.balance * rates[currency]
return balances
}, {} as Record<Currency, number>)
}
get data() { return this.#data }
get rates() { return this.#rates }
get balances() { return this.#balances }
}
export const portfolio = new Portfolio()
Here’s where things get tricky. When you update the account balance, you might expect only the portfolio calculations to re-run. But it will trigger the exchange rates to reload too, even though the currency didn’t change.
This wastes time, database reads, and CPU cycles. Also, because the rates are initially undefined when the new RatesState
instance is instantiated, the #balances
will momentarilly become undefined which could cause the UI to flicker and if multiple accounts are rendered in a list it could scroll the page back to the top.
Ultimately, all the benefits of “fine grained reactivity” are lost if the entire UI is cleared and then re-rendered.
Why this happens: When Firestore sends a new data object, Svelte sees the entire object as “changed” even if individual properties have the same values. This triggers the #rates
dependency to recreate the RatesState
instance.
By creating a separate $derived
value for the account data currency any updates to it will be de-duplicated and we can then use it as the dependency for the rates. This avoids the rates being reloaded unless the currency itself actually changes.
export class Portfolio {
#data: DataState
#currency?: Currency
#rates: RatesState
#balances?: Record<string, number>
constructor() {
this.#data = $derived(new DataState(user.current))
// Extract the currency to avoid unnecessary updates
this.#currency = $derived(this.#data.current?.currency)
this.#rates = $derived(new RatesState(this.#currency))
this.#balances = $derived(this.calculateBalances(
this.#data.current,
this.#rates.current
))
}
// ... rest of implementation
}
Now the exchange rates only reload when the currency actually changes, not when the balance updates.
Here’s a simple UI that demonstrates the reactive state:
<script lang="ts">
import { portfolio } from '$lib/portfolio.svelte'
function updateBalance(event: Event) {
const input = event.target as HTMLInputElement
portfolio.data.update({ balance: input.valueAsNumber })
}
function updateCurrency(event: Event) {
const select = event.target as HTMLSelectElement
portfolio.data.update({ currency: select.value })
}
</script>
{#if portfolio.data.current}
<div class="controls">
<label>
Balance:
<input
type="number"
value={portfolio.data.current.balance}
on:change={updateBalance}
/>
</label>
<label>
Currency:
<select value={portfolio.data.current.currency} on:change={updateCurrency}>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
</label>
</div>
<section>
<h3>Account Data</h3>
<pre>{JSON.stringify(portfolio.data.current, null, 2)}</pre>
</section>
<section>
<h3>Exchange Rates</h3>
<pre>{JSON.stringify(portfolio.rates.current, null, 2)}</pre>
</section>
<section>
<h3>Portfolio Values</h3>
<pre>{JSON.stringify(portfolio.balances, null, 2)}</pre>
</section>
{/if}
<style>
.controls {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
section {
margin-bottom: 1.5rem;
}
h3 {
font-size: 1rem;
margin-bottom: 0.5rem;
}
</style>
Use createSubscriber
for external subscriptions like Firebase listeners. It automatically handles cleanup.
Separate concerns: Use classes for subscription management and $derived
for dependency chaining.
Extract stable dependencies: Don’t use properties of objects directly - changes to the object will trigger updates. Create separate $derived
state to de-dupelicate them.
Debug with $inspect
: Add $inspect(portfolio.rates.current)
to see when state unexpectedly resets to undefined
.
Singleton pattern works well: Export instances directly when you need app-wide state.
If you’re using Firestore, here are basic security rules that prevent the classic “open database” mistake - it’s really not difficult, there’s no excuse for exposing user data:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Deny all by default
match /{document=**} {
allow read, write: if false;
}
// Users can only access their own data
match /data/{uid} {
allow read, write: if request.auth != null && request.auth.uid == uid;
}
// Anyone can read exchange rates, but not modify them
match /rates/{currency} {
allow read: if true;
allow write: if false;
}
}
}
Svelte 5 Runes are powerful, but real applications reveal complexity that simple counter examples don’t capture. Understanding these patterns will help you build reactive apps that perform well and behave predictably.