All Articles

Why @FetchRequest Doesn't Work with Share Extensions (And What Does)

swift swiftui core-data share-extension claude-code

If you've built an iOS Share Extension that writes to a shared Core Data store, you've probably hit this wall: the data saves fine, but your main app's SwiftUI list doesn't update until you kill and relaunch it.

I hit this while building a bookmarking app during a live stream. I was using Claude Code to build the whole thing. Got the Share Extension working, got it saving to Core Data through an App Group, deployed to my phone, shared a YouTube link, and... the app just sat there showing the old data. Kill the app, relaunch, there it is. Cool. Very helpful.

I spent the next few hours figuring out why, and I'm writing it up so you don't have to.

The Setup

Nothing exotic going on here. A main app and a Share Extension both access the same Core Data SQLite store through an App Group container:

graph LR A[Main App] -->|reads/writes| B[(App Group Container
ShareSaver.sqlite)] C[Share Extension] -->|writes| B

Both targets share a PersistenceController that points the persistent store at the App Group path:

class PersistenceController {
    static let shared = PersistenceController()
    let container: NSPersistentContainer

    private static let appGroupID = "group.com.example.ShareSaver"

    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "ShareSaver")

        if let storeURL = FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupID)?
            .appendingPathComponent("ShareSaver.sqlite") {
            container.persistentStoreDescriptions.first!.url = storeURL
        }

        container.loadPersistentStores { _, error in
            if let error { fatalError("Store failed: \(error)") }
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
    }
}

The Share Extension saves items using a background context:

private func saveItem(text: String) {
    let context = PersistenceController.shared.container.newBackgroundContext()
    context.performAndWait {
        let item = SavedItem(context: context)
        item.id = UUID()
        item.text = text
        item.createdAt = Date()
        try? context.save()
    }
}

This all works fine. The data lands in SQLite. Kill the app, relaunch, the data shows up, so it's definitely being persisted. The problem is getting the main app to see that data without a relaunch.

Quick sidebar on why I went with Core Data instead of SwiftData: I was using Claude Code to build this project, and AI coding tools just aren't trained well enough on SwiftData yet. There's not enough content out there for them to do a great job with it. Plus SwiftData hasn't gone through the battle testing that Core Data has. So I stuck with what I know works. If you're doing agent-assisted coding, I'd honestly recommend the same. At least for now.

The @FetchRequest Approach

Here's what you'd write first, and what every Core Data + SwiftUI tutorial shows:

struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    @Environment(\.scenePhase) private var scenePhase

    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \SavedItem.createdAt, ascending: false)]
    )
    private var items: FetchedResults<SavedItem>

    var body: some View {
        List {
            ForEach(items) { item in
                Text(item.text ?? "")
            }
        }
        .onChange(of: scenePhase) {
            if scenePhase == .active {
                viewContext.refreshAllObjects()
            }
        }
    }
}

You share a link, switch back to the app, and... nothing. The list is stale. Only a full kill-and-relaunch shows the new item.

I already had a bad feeling about this when I saw @FetchRequest being used. I'll be honest; I don't love property wrappers. I think they partially ruined Swift. But what can you do 🤷🏽‍♂️

Why @FetchRequest Breaks Across Processes

Your Share Extension runs in a completely separate process. The extension has its own PersistenceController instance, its own NSPersistentContainer, and its own managed object context. When it calls context.save(), it writes directly to the SQLite file. The main app's viewContext has no idea that happened.

@FetchRequest creates an NSFetchedResultsController under the hood that listens for NSManagedObjectContextObjectsDidChange notifications on the viewContext. Objects inserted, updated, or deleted within that context trigger the notification and SwiftUI re-renders. Cross-process writes don't trigger any of that. The Share Extension writes to SQLite, and the main app's viewContext is sitting there working with a stale in-memory cache.

refreshAllObjects() doesn't help either. It re-faults every managed object currently registered in the context, but it can't discover new objects. If the Share Extension inserted a row that this context has never seen, refreshAllObjects() has nothing to refresh. The context doesn't even know the row exists.

The Debugging Spiral

My first thought was that it's probably a lifecycle thing. Like, maybe the scene delegate isn't detecting that the app re-entered the foreground, so the @FetchRequest isn't getting triggered to refresh. I had Claude implement the scene phase observer. Rebuilt, deployed to my phone. Didn't fix it.

OK, so it's not a foreground/background thing. Even though I was pretty sure by now that it wasn't a simple refresh issue (since the data was there on relaunch) I still wanted to try pull-to-refresh. Not because I thought it would fix it, but because it would give me better feedback. If I manually trigger a fetch and it still doesn't work, then I know the problem is deeper than just "we're not refreshing at the right time."

Implemented pull-to-refresh. Still broken.

At this point I was running out of time on the live stream, and I started going deeper. I had Claude try Persistent History Tracking, which is Apple's recommended pattern for cross-process Core Data changes. I tried Apple's class method mergeChanges(fromRemoteContextSave:into:). I tried wiring the history merge into the scene phase observer. Every Stack Overflow answer and Apple Developer Forums thread I could find said persistent history tracking was the answer.

None of it worked. I kept sending Claude back in to fix it, and each time it would say "OK, updated"... and each time, same result.

Checking Under the Hood

I wanted to see what the data layer was actually saying versus what the UI was showing, so I built a debug panel directly into the app. Three counts, side by side:

Source Count
Fresh NSFetchRequest on background context 11
viewContext.count(for:) 11
@FetchRequest (SwiftUI) 10

OK, so I wasn't crazy. The data is in the store. The viewContext can see it. @FetchRequest just won't re-evaluate. The problem isn't Core Data at all. It's that @FetchRequest doesn't pick up cross-process changes no matter what you do. The property wrapper was the problem the whole time.

The Fix: Do It the UIKit Way

Once I saw that debug output, I was like... you know what, Claude, implement this the way we would've done it back in the UIKit days. Manage the NSFetchedResultsController yourself. I almost guarantee it's gonna work.

Sure enough. That shit worked.

class SavedItemListViewModel: NSObject, ObservableObject,
    NSFetchedResultsControllerDelegate {

    @Published var items: [SavedItem] = []

    private let fetchedResultsController: NSFetchedResultsController<SavedItem>
    private let viewContext: NSManagedObjectContext

    init(context: NSManagedObjectContext) {
        self.viewContext = context

        let request: NSFetchRequest<SavedItem> = NSFetchRequest(entityName: "SavedItem")
        request.sortDescriptors = [
            NSSortDescriptor(keyPath: \SavedItem.createdAt, ascending: false)
        ]

        fetchedResultsController = NSFetchedResultsController(
            fetchRequest: request,
            managedObjectContext: context,
            sectionNameKeyPath: nil,
            cacheName: nil
        )

        super.init()

        fetchedResultsController.delegate = self
        try? fetchedResultsController.performFetch()
        items = fetchedResultsController.fetchedObjects ?? []
    }

    func controllerDidChangeContent(
        _ controller: NSFetchedResultsController<any NSFetchRequestResult>
    ) {
        items = fetchedResultsController.fetchedObjects ?? []
    }

    func reload() {
        viewContext.reset()
        try? fetchedResultsController.performFetch()
        items = fetchedResultsController.fetchedObjects ?? []
    }

    func delete(at offsets: IndexSet) {
        for index in offsets {
            viewContext.delete(items[index])
        }
        try? viewContext.save()
    }
}

The key is reload(). reset() nukes the context's in-memory cache so performFetch() actually hits the database instead of returning stale objects. Then you update the @Published array and SwiftUI re-renders. You're controlling everything from the context manager, the way it was always meant to work.

The view:

struct ContentView: View {
    @Environment(\.scenePhase) private var scenePhase
    @StateObject private var vm: SavedItemListViewModel

    init() {
        let context = PersistenceController.shared.container.viewContext
        _vm = StateObject(wrappedValue: SavedItemListViewModel(context: context))
    }

    var body: some View {
        NavigationView {
            List {
                ForEach(vm.items) { item in
                    Text(item.text ?? "")
                }
                .onDelete { vm.delete(at: $0) }
            }
            .navigationTitle("Saved Items")
            .refreshable { vm.reload() }
            .onChange(of: scenePhase) {
                if scenePhase == .active {
                    vm.reload()
                }
            }
        }
    }
}

When the user shares content and returns to the app, scenePhase transitions to .active, reload() fires, and the new item appears. First try. Pull-to-refresh works too. In-process changes like swipe-to-delete are handled automatically by the NSFetchedResultsControllerDelegate.

What's Actually Going On

@FetchRequest is a convenience wrapper that works great when all changes flow through the same viewContext in a single process. But when another process writes to the same SQLite file (like a Share Extension does) the property wrapper's internal observation just doesn't pick it up. Even if you merge the changes exactly the way Apple's documentation says to, @FetchRequest doesn't re-evaluate. The data is right there in the context, and it just ignores it.

With the NSFetchedResultsController approach, you're not trying to convince the property wrapper that something changed. You nuke the cache, re-fetch from disk, and update the @Published array yourself. No persistent history tracking, no Darwin notification hacks. Just reset() and performFetch().

If You're Using AI Coding Tools

I built this whole app with Claude Code and it worked pretty damn well, but this is where it got stuck. It kept trying persistent history tracking because that's what the docs say, not realizing @FetchRequest was the actual bottleneck. I had to be the one to say "forget the property wrapper, just use NSFetchedResultsController." Sometimes you have to override the agent with your own instincts.

The sample project has the broken @FetchRequest version commented out alongside the working NSFetchedResultsController version so you can see the difference yourself.