What I Was Trying to Do

I was building an iOS music streaming app with detail views behind NavigationDestination. Each detail view had its own ViewModel, with API calls injected as closures.

Something like this:

@Observable
final class PlaylistDetailVM {
    var songs: [Song] = []
    var isLoading = false

    // API methods injected as closures
    private let fetchSongs: (String) async throws -> [Song]
    private let addToPlaylist: (String, [String]) async throws -> Void

    init(
        fetchSongs: @escaping (String) async throws -> [Song],
        addToPlaylist: @escaping (String, [String]) async throws -> Void
    ) {
        self.fetchSongs = fetchSongs
        self.addToPlaylist = addToPlaylist
    }
}

Created as @State inside NavigationDestination:

.navigationDestination(for: Playlist.self) { playlist in
    let vm = PlaylistDetailVM(
        fetchSongs: { id in try await client.getPlaylist(id: id).entry },
        addToPlaylist: { id, songIds in try await client.addToPlaylist(id: id, songIds: songIds) }
    )
    PlaylistDetailView(vm: vm)
}

It Crashed

EXC_BAD_ACCESS — memory corruption. Straight away.

Attempt 1: @ObservationIgnored

Maybe the closures were being tracked by the Observation macro? Added @ObservationIgnored:

@Observable
final class PlaylistDetailVM {
    var songs: [Song] = []
    @ObservationIgnored private let fetchSongs: (String) async throws -> [Song]
    // ...
}

Still crashed. @ObservationIgnored only skips change tracking — it doesn’t fix the underlying memory layout issue.

Attempt 2: Separate Closure Container

Extracted closures into a plain class:

final class APIActions {
    let fetchSongs: (String) async throws -> [Song]
    let addToPlaylist: (String, [String]) async throws -> Void
    // ...
}

@Observable
final class PlaylistDetailVM {
    var songs: [Song] = []
    @ObservationIgnored private let actions: APIActions
    // ...
}

Still crashed. The @Observable macro’s memory layout transformation was corrupting reference-type properties regardless of wrapping.

The Fix: Drop the ViewModel

The problem was creating an @Observable VM as @State inside NavigationDestination — with closure properties, specifically.

The solution was surprisingly simple — don’t use a ViewModel at all.

struct PlaylistDetailView: View {
    let client: SubsonicClient       // Pass the API client directly
    let playlistId: String

    @State private var songs: [Song] = []   // State as value types
    @State private var isLoading = false
    @State private var errorMessage: String?

    var body: some View {
        List {
            // ...
        }
        .task {
            // Call client methods directly
            isLoading = true
            do {
                let detail = try await client.getPlaylist(id: playlistId)
                songs = detail.entry
            } catch {
                errorMessage = error.localizedDescription
            }
            isLoading = false
        }
    }
}

Manage state with @State value types, call API client methods directly. The crash disappeared completely.

풀 플레이어 blur 배경

When @Observable Still Works

I didn’t abandon it entirely. It’s still great for app-wide shared state:

@Observable
final class PlayerVM {
    var currentSong: Song?
    var queue: [Song] = []
    var isPlaying = false
    // No closure properties!
}

The key rule: no closures in @Observable classes. Plain value-type properties (String, Int, [Song], etc.) work perfectly fine.

Takeaway

@Observable + closure properties = memory corruption. For NavigationDestination views, use @State value types + direct client calls instead of a ViewModel.

If you’re migrating from ObservableObject to @Observable, watch out — closure injection was a common DI pattern with ObservableObject, but it doesn’t survive the transition safely.