Captain Codeman Captain Codeman

Svelte 5 Runes: Real-World Patterns and Gotchas

Subtle mistakes to avoid

Contents

Introduction

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.

The Challenge: Cascading State Dependencies

Our app has this dependency chain:

  • User authentication → loads account data
  • Account data (balance + currency) → loads exchange rates
  • Account data + Exchange rates → calculates portfolio value in multiple currencies

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.

Pattern 1: Auth State with createSubscriber

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

Pattern 2: Dependent State with Conditional Subscriptions

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.

Pattern 3: Chaining Dependencies with $derived

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()

The Gotcha: Over-Reactive Updates

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.

Solution: Extract Stable Dependencies

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.

A Complete Working Example

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>

Key Takeaways

  1. Use createSubscriber for external subscriptions like Firebase listeners. It automatically handles cleanup.

  2. Separate concerns: Use classes for subscription management and $derived for dependency chaining.

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

  4. Debug with $inspect: Add $inspect(portfolio.rates.current) to see when state unexpectedly resets to undefined.

  5. Singleton pattern works well: Export instances directly when you need app-wide state.

Security Note

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.