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

  1. Setting up SwiftData for local storage
  2. Configuring Firebase as a remote store
  3. Synchronizing data between SwiftData and Firebase with DTOs
  4. 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!

Stephen Dixon

Stephen Dixon

iOS Developer. Previously at strong.app and buffer.com. Founder ios-developers.io. Building and designing for screens since 1998!
Manchester, England