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.

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@Statevalue 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.