Chapter 5: Bridging NSFetchedResultsController to SwiftUI

  • Massive Views: They forcefully bind your Core Data context directly to your UI views, leading to views that are overly aware of data persistence mechanics.
  • Testing Nightmare: It is practically impossible to unit test fetching logic when it is embedded inside a SwiftUI property wrapper.
  • Rigid Paradigms: Customizing data delivery, debouncing rapid changes, or mapping managed objects to view models becomes exceedingly difficult.

The Architecture: Why NSFetchedResultsController?

  1. Memory Efficiency and Faulting: FRC automatically batches fetches using fetchBatchSize. When dealing with 100,000 records, it doesn't load them all into memory. It fetches an array of lightweight NSManagedObjectID references and only "faults" (fully loads) the objects as they are demanded by the UI.
  2. Granular Reactivity: Through NSFetchedResultsControllerDelegate, it actively listens to the NSManagedObjectContext for any insertions, deletions, or updates that match its fetch request. It is highly optimized to only notify you when changes affect its specific query.
  3. Sectioning and Caching: It natively supports grouping data into sections based on a specific property, and can cache the section geometry to disk, making subsequent loads lightning fast.
flowchart TD subgraph Core_Data_Layer ["Core Data Layer"] Context[NSManagedObjectContext] Store[(NSPersistentStore - SQLite)] end subgraph Data_Repository_Layer ["Data Repository Layer"] FRC[NSFetchedResultsController] Delegate[FRC Delegate] Repo[ExpenseRepository] Cache[(FRC Cache)] end subgraph Presentation_Layer ["Presentation Layer"] VM[ExpenseListViewModel] end subgraph UI_Layer ["UI Layer"] View[SwiftUI ExpenseListView] end Context <--> Store FRC -->|"1. Fetches from"| Context FRC -.->|"2. Reads/Writes"| Cache Context -->|"3. Notifies changes to"| FRC FRC -->|"4. Triggers"| Delegate Delegate -->|"5. Pushes Array to"| Repo Repo -->|"6. AsyncStream of Expenses"| VM VM -->|"7. @Observable"| View

Setting up the Expense Repository

import Foundation
import CoreData

/// Protocol defining the interface for our expense repository.
protocol ExpenseRepositoryProtocol {
    var expensesStream: AsyncStream<[Expense]> { get }
    func fetchExpenses()
    func delete(expense: Expense)
}

final class ExpenseRepository: NSObject, ExpenseRepositoryProtocol {
    
    private let context: NSManagedObjectContext
    private let fetchedResultsController: NSFetchedResultsController<Expense>
    
    // We use an AsyncStream continuation to yield the current state of our expenses.
    // This ensures new consumers immediately receive the latest data.
    private var expensesContinuation: AsyncStream<[Expense]>.Continuation?
    public lazy var expensesStream: AsyncStream<[Expense]> = {
        AsyncStream { continuation in
            self.expensesContinuation = continuation
        }
    }()
    
    init(context: NSManagedObjectContext) {
        self.context = context
        
        // 1. Create the Fetch Request
        let fetchRequest: NSFetchRequest<Expense> = Expense.fetchRequest()
        
        // 2. Add Sort Descriptors (Required for NSFetchedResultsController)
        // Without at least one sort descriptor, the FRC will crash at runtime.
        // The SQLite database uses this to order the fetched object IDs.
        fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Expense.date, ascending: false)]
        
        // 3. Optimize Memory with Batch Size
        // This tells SQLite to fetch rows in chunks of 20. 
        fetchRequest.fetchBatchSize = 20
        
        // 4. Initialize the NSFetchedResultsController
        self.fetchedResultsController = NSFetchedResultsController(
            fetchRequest: fetchRequest,
            managedObjectContext: context,
            sectionNameKeyPath: nil, 
            cacheName: "ExpenseListCache" // Optional: Caches the pre-computed fetch geometry
        )
        
        super.init()
        
        // 5. Assign the delegate to listen for context changes
        self.fetchedResultsController.delegate = self
    }
    
    func fetchExpenses() {
        do {
            try fetchedResultsController.performFetch()
            // Yield the initial fetch results to our stream
            updateSnapshot()
        } catch {
            print("Failed to fetch expenses: \(error.localizedDescription)")
            // In a production app, handle this error properly
        }
    }
    
    func delete(expense: Expense) {
        context.delete(expense)
        // While we save here for simplicity, in complex apps, saving is usually 
        // delegated to a centralized Unit of Work or Context Manager.
        do {
            try context.save()
        } catch {
            print("Failed to delete expense: \(error.localizedDescription)")
        }
    }
    
    private func updateSnapshot() {
        // Extract the fetched objects safely
        let fetchedExpenses = fetchedResultsController.fetchedObjects ?? []
        // Push the new array downstream
        expensesContinuation?.yield(fetchedExpenses)
    }
}

Deep Dive: Repository Construction & Performance

  1. AsyncStream: We bridge the imperative delegate pattern of FRC to a modern Swift Concurrency stream. By yielding the array of Expense objects to the stream, we aren't duplicating memory. We are simply holding references to NSManagedObject instances, many of which remain unfulfilled "faults" until the UI asks for their properties.
  2. fetchBatchSize vs fetchLimit: A common misconception. fetchLimit caps the total number of items returned (e.g., "Top 10"). fetchBatchSize dictates memory management. If a user has 10,000 expenses, a batch size of 20 means Core Data retrieves 10,000 row IDs from SQLite, but only populates data for 20 objects at a time as you scroll.
  3. cacheName: When you provide a cacheName, the FRC saves the pre-computed section information and object ordering to a file. On subsequent launches, it reads this cache instead of querying SQLite, offering a massive speedup.

    [!WARNING] If you change the fetchRequest (e.g., applying a different predicate or sort descriptor), you must call NSFetchedResultsController.deleteCache(withName:) before initializing the new FRC, otherwise your app will crash or display stale data.

Mastering NSFetchedResultsControllerDelegate in SwiftUI

import Foundation
import CoreData

protocol ExpenseRepositoryProtocol {
    var expensesStream: AsyncStream<[Expense]> { get }
    func fetchExpenses()
    func delete(expense: Expense)
}

final class ExpenseRepository: NSObject, ExpenseRepositoryProtocol, NSFetchedResultsControllerDelegate {
    
    private let context: NSManagedObjectContext
    let fetchedResultsController: NSFetchedResultsController<Expense>
    
    // We use an AsyncStream continuation to yield the current state of our expenses.
    // This ensures new consumers immediately receive the latest data.
    private var expensesContinuation: AsyncStream<[Expense]>.Continuation?
    public lazy var expensesStream: AsyncStream<[Expense]> = {
        AsyncStream { continuation in
            self.expensesContinuation = continuation
        }
    }()
    
    init(context: NSManagedObjectContext) {
        self.context = context
        
        // 1. Create the Fetch Request
        let fetchRequest: NSFetchRequest<Expense> = Expense.fetchRequest()
        
        // 2. Add Sort Descriptors (Required for NSFetchedResultsController)
        // Without at least one sort descriptor, the FRC will crash at runtime.
        // The SQLite database uses this to order the fetched object IDs.
        fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Expense.date, ascending: false)]
        
        // 3. Optimize Memory with Batch Size
        // This tells SQLite to fetch rows in chunks of 20. 
        fetchRequest.fetchBatchSize = 20
        
        // 4. Initialize the NSFetchedResultsController
        self.fetchedResultsController = NSFetchedResultsController(
            fetchRequest: fetchRequest,
            managedObjectContext: context,
            sectionNameKeyPath: nil, 
            cacheName: "ExpenseListCache" // Optional: Caches the pre-computed fetch geometry
        )
        
        super.init()
        
        // 5. Assign the delegate to listen for context changes
        self.fetchedResultsController.delegate = self
    }
    
    func fetchExpenses() {
        do {
            try fetchedResultsController.performFetch()
            // Yield the initial fetch results to our stream
            updateSnapshot()
        } catch {
            print("Failed to fetch expenses: \(error.localizedDescription)")
            // In a production app, handle this error properly
        }
    }
    
    func delete(expense: Expense) {
        context.delete(expense)
        // While we save here for simplicity, in complex apps, saving is usually 
        // delegated to a centralized Unit of Work or Context Manager.
        do {
            try context.save()
        } catch {
            print("Failed to delete expense: \(error.localizedDescription)")
        }
    }
    
    func updateSnapshot() {
        // Extract the fetched objects safely
        let fetchedExpenses = fetchedResultsController.fetchedObjects ?? []
        // Push the new array downstream
        expensesContinuation?.yield(fetchedExpenses)
    }
    
    // Called after the FRC has processed all changes in the current event loop.
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        updateSnapshot()
    }
}

Threading Considerations & Contexts

[!IMPORTANT] The NSFetchedResultsController must be initialized on the same thread as its associated NSManagedObjectContext. Furthermore, its delegate methods will be invoked on that same thread.

Constructing the ViewModel

import Foundation
import Observation
import SwiftUI

@MainActor
@Observable
final class ExpenseListViewModel {
    
    var expenses: [Expense] = []
    
    private let repository: ExpenseRepositoryProtocol
    
    init(repository: ExpenseRepositoryProtocol) {
        self.repository = repository
        setupSubscriptions()
        
        // Trigger the initial fetch on load
        repository.fetchExpenses()
    }
    
    private func setupSubscriptions() {
        Task { @MainActor in
            for await newExpenses in repository.expensesStream {
                self.expenses = newExpenses
            }
        }
    }
    
    func deleteExpense(at offsets: IndexSet) {
        for index in offsets {
            let expense = expenses[index]
            repository.delete(expense: expense)
        }
    }
    
    // Formatting logic kept out of the View
    func formatAmount(_ amount: Double) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.currencyCode = "USD"
        return formatter.string(from: NSNumber(value: amount)) ?? "$0.00"
    }
}

Building the SwiftUI View

[!TIP] Sometimes, when inserting new objects, they have a temporary NSManagedObjectID until the context is saved. This can occasionally confuse SwiftUI's List diffing algorithm if you rely implicitly on Identifiable. It is often safer to explicitly define the id parameter as \.objectID in your ForEach.

import SwiftUI

struct ExpenseListView: View {
    
    @State private var viewModel: ExpenseListViewModel
    
    init(viewModel: ExpenseListViewModel) {
        _viewModel = State(wrappedValue: viewModel)
    }
    
    var body: some View {
        NavigationView {
            List {
                // Explicitly using \.objectID for stable diffing
                ForEach(viewModel.expenses, id: \.objectID) { expense in
                    ExpenseRowView(expense: expense)
                }
                .onDelete(perform: viewModel.deleteExpense)
            }
            .navigationTitle("Expenses")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button(action: { /* Present add sheet */ }) {
                        Image(systemName: "plus")
                    }
                }
            }
            .overlay(
                Group {
                    if viewModel.expenses.isEmpty {
                        Text("No expenses yet. Tap + to add one.")
                            .foregroundColor(.secondary)
                    }
                }
            )
        }
    }
}

The "Related Object" Gotcha in NSFetchedResultsController

struct ExpenseRowView: View {
    // @ObservedObject forces the row to re-render if ANY property 
    // or relationship on this specific NSManagedObject changes.
    @ObservedObject var expense: Expense
    
    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 4) {
                Text(expense.title ?? "Unknown")
                    .font(.headline)
                
                // If category name changes elsewhere, @ObservedObject catches it
                if let categoryName = expense.category?.name {
                    Text(categoryName)
                        .font(.caption)
                        .padding(4)
                        .background(Color.blue.opacity(0.1))
                        .cornerRadius(4)
                }
            }
            Spacer()
            Text(String(format: "$%.2f", expense.amount))
        }
        .padding(.vertical, 4)
    }
}

Advanced FRC: Sectioned Data

[!CAUTION] Never use a transient (computed) property as the sectionNameKeyPath. The FRC uses SQLite to group the objects. If you use a transient property, Core Data must load every single object into memory to calculate the grouping, entirely negating the benefits of batching.

import Foundation
import CoreData

func configureSectionedFRC(context: NSManagedObjectContext) -> NSFetchedResultsController<Expense> {
    let fetchRequest: NSFetchRequest<Expense> = Expense.fetchRequest()

    // 1st Sort Descriptor MUST match the section grouping key path
    let categorySort = NSSortDescriptor(keyPath: \Expense.category?.name, ascending: true)
    // 2nd Sort Descriptor orders items within the section
    let dateSort = NSSortDescriptor(keyPath: \Expense.date, ascending: false)

    fetchRequest.sortDescriptors = [categorySort, dateSort]

    return NSFetchedResultsController(
        fetchRequest: fetchRequest,
        managedObjectContext: context,
        sectionNameKeyPath: "category.name", // Group by category name
        cacheName: nil
    )
}

Bridging Sections to AsyncStream

struct ExpenseSection: Identifiable {
    let id = UUID()
    let name: String
    let expenses: [Expense]
}
import Foundation
import CoreData

final class SectionedExpenseRepository: NSObject, NSFetchedResultsControllerDelegate {
    
    private let context: NSManagedObjectContext
    private let fetchedResultsController: NSFetchedResultsController<Expense>
    
    private var sectionsContinuation: AsyncStream<[ExpenseSection]>.Continuation?
    public lazy var sectionsStream: AsyncStream<[ExpenseSection]> = {
        AsyncStream { continuation in
            self.sectionsContinuation = continuation
        }
    }()
    
    init(context: NSManagedObjectContext) {
        self.context = context
        let fetchRequest: NSFetchRequest<Expense> = Expense.fetchRequest()
        fetchRequest.sortDescriptors = [
            NSSortDescriptor(keyPath: \Expense.category?.name, ascending: true),
            NSSortDescriptor(keyPath: \Expense.date, ascending: false)
        ]
        self.fetchedResultsController = NSFetchedResultsController(
            fetchRequest: fetchRequest,
            managedObjectContext: context,
            sectionNameKeyPath: "category.name",
            cacheName: nil
        )
        super.init()
        self.fetchedResultsController.delegate = self
    }
    
    func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
        updateSnapshot()
    }
    
    private func updateSnapshot() {
        guard let sections = fetchedResultsController.sections else { return }
        
        let mappedSections = sections.map { sectionInfo -> ExpenseSection in
            // sectionInfo.objects contains the faults for this specific section
            let expenses = (sectionInfo.objects as? [Expense]) ?? []
            return ExpenseSection(name: sectionInfo.name, expenses: expenses)
        }
        
        sectionsContinuation?.yield(mappedSections)
    }
}
classDiagram class NSFetchedResultsController { +NSFetchRequest fetchRequest +String sectionNameKeyPath +Array~NSFetchedResultsSectionInfo~ sections +performFetch() } class NSFetchedResultsSectionInfo { <> +String name +String indexTitle +Int numberOfObjects +Array objects } NSFetchedResultsController "1" *-- "many" NSFetchedResultsSectionInfo : contains

Advanced Techniques: Dynamic Filtering and Manual Fetching

import Foundation
import CoreData

extension ExpenseRepository {
    func updateSearchQuery(_ query: String) {
        // 1. Purge the cache if you are modifying the fetch request!
        NSFetchedResultsController<Expense>.deleteCache(withName: "ExpenseListCache")
        
        let fetchRequest = fetchedResultsController.fetchRequest
        
        if query.isEmpty {
            fetchRequest.predicate = nil
        } else {
            // [cd] implies case-insensitive and diacritic-insensitive
            fetchRequest.predicate = NSPredicate(format: "title CONTAINS[cd] %@", query)
        }
        
        do {
            // 2. Re-perform the fetch 
            try fetchedResultsController.performFetch()
            // 3. Manually trigger an update (performFetch doesn't call the delegate)
            updateSnapshot() 
        } catch {
            print("Search fetch failed: \(error)")
        }
    }
}
import Foundation
import Observation

@Observable
class ExpenseListViewModel {
    private let repository: ExpenseRepository
    private var searchTask: Task<Void, Never>?
    
    init(repository: ExpenseRepository) {
        self.repository = repository
    }

    var searchText = "" {
        didSet {
            // Cancel previous task and create a new one to debounce
            searchTask?.cancel()
            searchTask = Task { @MainActor in
                try? await Task.sleep(nanoseconds: 300_000_000)
                if !Task.isCancelled {
                    repository.updateSearchQuery(searchText)
                }
            }
        }
    }
}

Faulting, Batching, and the "UI Freeze" Constraint

import Foundation

func calculateBadTotalSum(expenses: [Expense]) -> Double {
    // A VERY BAD IDEA if fetchBatchSize is enabled and the list is large
    return expenses.reduce(0) { $0 + $1.amount }
}
import Foundation
import CoreData

extension ExpenseRepository {
    func calculateTotalAmount() -> Double {
        let fetchRequest = NSFetchRequest(entityName: "Expense")
        fetchRequest.resultType = .dictionaryResultType
        
        let sumExpressionDesc = NSExpressionDescription()
        sumExpressionDesc.name = "sumAmount"
        sumExpressionDesc.expression = NSExpression(forFunction: "sum:", arguments: [NSExpression(forKeyPath: "amount")])
        sumExpressionDesc.expressionResultType = .doubleAttributeType
        
        fetchRequest.propertiesToFetch = [sumExpressionDesc]
        
        do {
            let results = try context.fetch(fetchRequest)
            if let resultDict = results.first, let sum = resultDict["sumAmount"] as? Double {
                return sum
            }
        } catch {
            print("Failed to calculate sum: \(error)")
        }
        return 0.0
    }
}

Summary