Syncing SwiftData with Firebase in Swift 6: A Complete Guide for Swift Concurrency
Synchronizing data between local and remote sources can be complex, especially when we want clean, maintainable code. In this article, we’ll set up SwiftData for local storage and Firebase for remote storage, using Swift Concurrency and a DTO (Data Transfer Object) pattern for efficient and concurrency-safe synchronization.
What We’ll Cover
- Setting up SwiftData for local storage
- Configuring Firebase as a remote store
- Synchronizing data between SwiftData and Firebase with DTOs
- Examples of data fetching, saving, and updating
Why Use Swift Concurrency and DTOs?
Swift Concurrency lets us write asynchronous code that's easy to read and avoids thread issues, while the DTO pattern enables us to separate our app’s model from Firebase. This makes the code more robust to changes and helps keep the app’s data layer organized.
Step 1: Setting Up SwiftData for Local Storage
For our example, let’s use a Book model representing a library book collection. By using SwiftData, we can manage data persistence directly on the device.
import SwiftData
@Observable
struct Book {
var id: String
var title: String
var author: String
var genre: String
}
To manage data storage in SwiftData, we’ll create a BookLocalDataStore
class to handle adding and fetching data asynchronously.
import SwiftData
final class BookLocalDataStore {
private let storage = PersistentStorage(name: "LibraryData")
init() {
storage.load { error in
if let error = error {
print("Error loading storage: \(error.localizedDescription)")
}
}
}
func saveBook(_ book: Book) async throws {
try await storage.save(book)
}
func fetchBooks() async throws -> [Book] {
return try await storage.fetchAll(Book.self)
}
}
Step 2: Configuring Firebase for Remote Data Storage
With Firebase, we can store a remote version of each Book
. We’ll also create a BookDTO
struct for our DTO pattern, keeping the data structure for Firebase separate from our SwiftData model.
import Foundation
struct BookDTO: Codable {
var id: String
var title: String
var author: String
var genre: String
func toBook() -> Book {
Book(id: id, title: title, author: author, genre: genre)
}
init(from book: Book) {
self.id = book.id
self.title = book.title
self.author = book.author
self.genre = book.genre
}
}
Step 3: Syncing Data Between Local and Remote
Let’s create a BookRepository
to manage data fetching from Firebase and syncing with SwiftData. This repository will fetch the list of books from Firebase, save them locally, and ensure any new books get added to both stores.
import FirebaseFirestore
import SwiftData
final class BookRepository {
private let localDataStore = BookLocalDataStore()
private let db = Firestore.firestore().collection("books")
// Fetch books from Firebase and save to local storage
func syncBooks() async throws {
let snapshot = try await db.getDocuments()
let books = snapshot.documents.compactMap { document -> BookDTO? in
try? document.data(as: BookDTO.self)
}
for bookDTO in books {
let book = bookDTO.toBook()
try await localDataStore.saveBook(book)
}
}
// Save new book to Firebase and sync with local
func addBook(_ book: Book) async throws {
let bookDTO = BookDTO(from: book)
try await db.document(book.id).setData(from: bookDTO)
try await localDataStore.saveBook(book)
}
// Fetch all books locally
func fetchBooks() async throws -> [Book] {
try await localDataStore.fetchBooks()
}
}
Step 4: Using BookRepository in Your App
Now let’s use BookRepository
within a view model to fetch and display the books.
@Observable
final class BookViewModel {
private let repository = BookRepository()
var books: [Book] = []
init() {
Task {
await loadBooks()
}
}
func loadBooks() async {
do {
books = try await repository.fetchBooks()
} catch {
print("Error loading books: \(error)")
}
}
func addBook(title: String, author: String, genre: String) async {
let newBook = Book(id: UUID().uuidString, title: title, author: author, genre: genre)
do {
try await repository.addBook(newBook)
books.append(newBook)
} catch {
print("Error adding book: \(error)")
}
}
}
Handling Error States and Edge Cases
To handle any potential issues, you might want to consider customizing error handling, possibly with specific error messages for different scenarios (e.g., network errors, database errors).
Wrapping Up
With SwiftData for local persistence, Firebase for remote storage, and DTOs to manage structured data transfer, this setup lets us build an efficient, concurrency-safe, and highly maintainable data layer. Adopting Swift Concurrency without Combine keeps our code simple and modern.
This approach provides the resilience and simplicity needed to build a seamless data layer for any app.
How did I do? Did I miss anything, or could I have explained something better? I’d love to hear your thoughts—reach out to me on X at @stphndxn or get in touch!