iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔥

SwiftUI + Firebase Architecture: Implementing @Observable with Firestore Real-time Listeners

に公開

Introduction

I am developing a personal iOS app called "Kaetao" (Towel Changer) that manages the replacement timing of towels.

https://10half.jp/kaetao.html

The backend uses Firebase (Auth / Firestore / Storage), and the UI is built with SwiftUI. For state management, I use the Observation framework (@Observable), which was introduced in iOS 17.

While the combination of SwiftUI + Firebase is common, I feel there are still few practical examples of @Observable + Firestore real-time listeners, so I would like to introduce the architecture of this app.

Overall Architecture

┌──────────────────────────────────────────────────┐
│  Views (SwiftUI struct)                           │
│  ContentView → TabView                            │
│    ├─ TowelListView                               │
│    │    └─ TowelDetailView                        │
│    └─ SettingsView                                │
├──────────────────────────────────────────────────┤
│  ViewModels (@Observable final class)             │
│  TowelListViewModel  ← TowelListView             │
│  TowelDetailViewModel ← TowelDetailView          │
├──────────────────────────────────────────────────┤
│  Services (Singleton, @Observable @MainActor)     │
│  AuthService.shared        — Firebase Auth        │
│  FirestoreService.shared   — Firestore CRUD       │
│  StorageService.shared     — Firebase Storage     │
│  GroupService.shared       — Group Management     │
├──────────────────────────────────────────────────┤
│  Models (Codable struct)                          │
│  Towel, ExchangeRecord, ConditionCheck            │
└──────────────────────────────────────────────────┘

The dependency direction is unidirectional: View → ViewModel → Service → Firestore. Although there are cases where the View references the Service directly, write operations are always performed through the Service.

Why @Observable?

Before iOS 17, the combination of ObservableObject + @Published + @StateObject / @ObservedObject was the standard. There are three reasons why I use @Observable.

1. Property-level update tracking

ObservableObject notifies of changes to the entire object via objectWillChange. Since @Observable tracks changes at the property level, Views are not re-rendered due to changes in unrelated properties.

2. Reduction of boilerplate

You no longer need to attach @Published to every property, and the distinction between @StateObject and @ObservedObject is unnecessary. On the View side, you just receive it with @State.

3. Keeping Services simple

Simply attaching @Observable to a Singleton Service class allows the UI to update automatically when a property referenced from a View changes. There is no need to write Combine's sink or assign.

Model Layer: Codable struct

struct Towel: Codable, Identifiable, Hashable {
    @DocumentID var id: String?
    var name: String = ""
    var location: String = ""
    var iconName: String = "hand.raised.fill"
    var exchangeIntervalDays: Int = 3
    @ServerTimestamp var createdAt: Date?
    @ServerTimestamp var updatedAt: Date?
    var lastExchangedAt: Date?

    // Not saved in Firestore — fetched from subcollections
    var records: [ExchangeRecord] = []
    var conditionChecks: [ConditionCheck] = []

    enum CodingKeys: String, CodingKey {
        case id, name, location, iconName, exchangeIntervalDays
        case createdAt, updatedAt, lastExchangedAt
    }
}

There are three main points:

1. @DocumentID and @ServerTimestamp

These are property wrappers provided by FirebaseFirestore that work with Codable to automatically map document IDs and server-side timestamps.

2. Exclude subcollection data from CodingKeys

records and conditionChecks are data fetched from Firestore subcollections. By not including them in CodingKeys, they are ignored during the decoding of the parent document.

3. Express business logic with computed properties

var status: TowelStatus {
    let remaining = Calendar.current.dateComponents(
        [.day], from: .now, to: nextExchangeDate
    ).day ?? 0
    if remaining < 0 {
        return .overdue
    } else if remaining <= 1 {
        return .soon
    } else {
        return .ok
    }
}

Pure logic like status calculation is placed in the Model. This is testable code that does not depend on ViewModels or Services.

Service Layer: @Observable Singleton

The core of the app is FirestoreService. As an @Observable Singleton, it handles Firestore real-time listeners and data persistence.

@Observable
@MainActor
final class FirestoreService {
    static let shared = FirestoreService()

    var towels: [Towel] = []
    var isLoading = true
    var errorMessage: String?

    private let db = Firestore.firestore()
    private var towelListener: ListenerRegistration?
    private var recordListeners: [String: ListenerRegistration] = [:]
    private var conditionCheckListeners: [String: ListenerRegistration] = [:]

    private init() {}
}

Automatic synchronization via real-time listeners

I use addSnapshotListener to subscribe to changes in Firestore. When data is updated, the towels property is rewritten, and thanks to @Observable tracking, all Views referencing it are automatically re-rendered.

func startListening() {
    guard let collection = towelsCollection() else { return }

    stopListening()
    isLoading = true

    towelListener = collection
        .order(by: "createdAt", descending: true)
        .addSnapshotListener { [weak self] snapshot, error in
            guard let self else { return }

            guard let documents = snapshot?.documents else {
                self.towels = []
                self.isLoading = false
                return
            }

            var newTowels = documents.compactMap { doc -> Towel? in
                try? doc.data(as: Towel.self)
            }

            // Carry over existing subcollection data
            for i in newTowels.indices {
                if let existing = self.towels.first(where: { $0.id == newTowels[i].id }) {
                    newTowels[i].records = existing.records
                    newTowels[i].conditionChecks = existing.conditionChecks
                }
            }

            self.towels = newTowels
            self.isLoading = false
        }
}

What is important here is carrying over subcollection data. While Towel is regenerated every time the parent document listener triggers, subcollection data (exchange records, condition checks) is not included in the parent's snapshot. By copying existing data to the new array, I maintain consistency with the subcollection listeners.

Lazy initialization of subcollection listeners

Subcollection (exchange records, condition checks) listeners are unnecessary when displaying the towel list. They are started only when the detail view is opened and stopped when it is closed.

// When opening the detail view
func startSubcollectionListeners(towelId: String) {
    subscribeToRecords(towelId: towelId)
    subscribeToConditionChecks(towelId: towelId)
}

// When closing the detail view
func stopSubcollectionListeners(towelId: String) {
    recordListeners[towelId]?.remove()
    recordListeners.removeValue(forKey: towelId)
    conditionCheckListeners[towelId]?.remove()
    conditionCheckListeners.removeValue(forKey: towelId)
}

The results of subcollection listeners are written directly to the corresponding Towel in the towels array.

private func subscribeToRecords(towelId: String, limit: Int? = subcollectionPageSize) {
    guard recordListeners[towelId] == nil,
          let collection = towelsCollection() else { return }

    var query: Query = collection.document(towelId).collection("records")
        .order(by: "exchangedAt", descending: true)
    if let limit {
        query = query.limit(to: limit)
    }

    let listener = query
        .addSnapshotListener { [weak self] snapshot, _ in
            guard let self, let documents = snapshot?.documents else { return }
            let records = documents.compactMap { try? $0.data(as: ExchangeRecord.self) }
            if let index = self.towels.firstIndex(where: { $0.id == towelId }) {
                self.towels[index].records = records
            }
        }

    recordListeners[towelId] = listener
}

Simply writing towels[index].records = records allows @Observable to detect the property change, and only the Views referencing those records are re-rendered.

Listener lifecycle management

The timing for starting and stopping listeners must be clearly managed.

App Launch

ContentView's .task

GroupService.loadGroupForCurrentUser()  ← Get Group ID

FirestoreService.startListening()       ← Start towel list listener

[User opens towel detail screen]

startSubcollectionListeners()           ← Start exchange/condition listener

[User closes detail screen]

stopSubcollectionListeners()            ← Stop exchange/condition listener

[Sign out]

FirestoreService.stopListening()        ← Stop all listeners

I also automatically clean up listeners for deleted towels.

// Inside parent listener callback
let currentIds = Set(newTowels.compactMap(\.id))
for key in self.recordListeners.keys where !currentIds.contains(key) {
    self.recordListeners[key]?.remove()
    self.recordListeners.removeValue(forKey: key)
}

ViewModel Layer: Separating Logic

The ViewModel's role is to separate View logic. It directly references the Service's towels array and performs filtering or sorting.

@Observable
final class TowelListViewModel {
    var searchText = ""
    var errorMessage: String?

    func filteredTowels(_ towels: [Towel]) -> [Towel] {
        guard !searchText.isEmpty else { return towels }
        return towels.filter { towel in
            towel.name.localizedCaseInsensitiveContains(searchText) ||
            towel.location.localizedCaseInsensitiveContains(searchText)
        }
    }

    func sortedByStatus(_ towels: [Towel]) -> [Towel] {
        towels.sorted { lhs, rhs in
            let lhsOrder = statusOrder(lhs.status)
            let rhsOrder = statusOrder(rhs.status)
            if lhsOrder != rhsOrder {
                return lhsOrder < rhsOrder
            }
            return lhs.daysSinceLastExchange > rhs.daysSinceLastExchange
        }
    }
}

Although the ViewModel is @Observable, it is not a Singleton. An instance is created for each View. The Service is responsible for data storage, while the ViewModel focuses on data processing.

View Layer: Direct Service Reference

Referencing @Observable properties of the Service directly from the View is a key feature of this architecture.

struct ContentView: View {
    @State private var firestoreService = FirestoreService.shared

    var body: some View {
        TabView {
            NavigationStack {
                TowelListView()
            }
            // ...
        }
        .task {
            await GroupService.shared.loadGroupForCurrentUser()
            firestoreService.startListening()
        }
        .onChange(of: firestoreService.towels) { _, towels in
            NotificationService.shared.rescheduleAllNotifications(for: towels)
        }
    }
}

I use @State private var firestoreService = FirestoreService.shared to hold a reference to the Singleton and use onChange to detect changes to firestoreService.towels and reschedule notifications.

Detail view ViewModel collaboration

In the detail view, the ViewModel manages the subcollection listener lifecycle.

@Observable
@MainActor
final class TowelDetailViewModel {
    let towelId: String

    var towel: Towel? {
        FirestoreService.shared.towels.first(where: { $0.id == towelId })
    }

    func startListening() {
        FirestoreService.shared.startSubcollectionListeners(towelId: towelId)
    }

    func stopListening() {
        FirestoreService.shared.stopSubcollectionListeners(towelId: towelId)
    }
}

The point is that towel is a computed property. Since it is re-evaluated whenever FirestoreService.shared.towels changes, real-time listener updates are automatically propagated from the ViewModel to the View.

Dual Path: Automatic Solo/Group Switching

This app has a family group sharing feature. When joining a group, the storage location for towel data switches from /users/{uid}/towels to /groups/{groupId}/towels.

private func towelsCollection() -> CollectionReference? {
    guard let userId else { return nil }

    if let groupId = GroupService.shared.groupId {
        return db.collection("groups").document(groupId).collection("towels")
    }
    return db.collection("users").document(userId).collection("towels")
}

Because all CRUD operations and listeners go through this towelsCollection(), callers do not need to be aware of solo/group status. Path switching is centralized in one location.

Pros and Cons of this Architecture

Pros

  • Less code — No need for Combine's sink / assign. No @Published required.
  • Reactive — Firestore changes are automatically reflected in the UI. No manual refresh needed.
  • Easier to debug — Data is consolidated in FirestoreService.shared.towels. You can see the whole picture in one place.
  • No SwiftData needed — You can use Firestore as the Single Source of Truth without local persistence.

Cons

  • Forgetting to stop listeners — Failing to call stopListening() causes memory leaks. Ensure all listeners are stopped upon sign-out.
  • Tight coupling of Singleton — Testability is sacrificed. I allow this for a personal project, but in team development, it should be abstracted with Protocols.
  • Subcollection carry-over — If you forget to copy subcollection data when the parent listener fires, the display on the detail screen will vanish.

Summary

  • The combination of @Observable + Firestore addSnapshotListener makes reactive UIs simple to implement.
  • By making the Service an @Observable Singleton, data can be accessed from any View.
  • Lazy-start subcollection listeners (only when opening the detail screen) to keep costs low.
  • Dual path (solo/group) is consolidated into a single towelsCollection() method, eliminating complexity for callers.
  • For the scale of a personal app, this configuration is sufficiently scalable.

I hope this is helpful to anyone struggling with the architecture of a SwiftUI app using Firebase.

The app "Kaetao" is available on the App Store.
https://10half.jp/kaetao.html

Discussion