ARTICLE AD BOX
Context
I'm building a multi-platform SwiftUI app targeting iOS 26 / macOS 26 (iPhone, iPad, Mac). I'm using MVVM with a Repository layer and the modern @Observable macro throughout. The app has list -> detail navigation and performs one-shot async data fetching.
My goal: enable rich Xcode Previews on all three platforms without spinning up real dependencies (network, database), while keeping the production ViewModel fast (no existential boxing overhead).
The pattern I landed on
1. ViewModel protocol
protocol RecipeListViewModelProtocol: AnyObject, Observable { var recipes: [Recipe] { get } var isLoading: Bool { get } func fetch() async @discardableResult func delete(id: UUID) async -> Bool }2. Production ViewModel
@Observable final class RecipeListViewModel: RecipeListViewModelProtocol { private(set) var recipes: [Recipe] = [] private(set) var isLoading = false private let repository: RecipeRepositoryProtocol init(repository: RecipeRepositoryProtocol = RecipeRepository()) { self.repository = repository } func fetch() async { isLoading = true defer { isLoading = false } recipes = (try? await repository.fetchAll()) ?? [] } @discardableResult func delete(id: UUID) async -> Bool { guard (try? await repository.delete(id: id)) != nil else { return false } await fetch() return true } }3. Mock ViewModel (preview injection)
@Observable final class RecipeListViewModelMock: RecipeListViewModelProtocol { var recipes: [Recipe] var isLoading: Bool init(recipes: [Recipe] = Recipe.samples, isLoading: Bool = false) { self.recipes = recipes self.isLoading = isLoading } func fetch() async {} @discardableResult func delete(id: UUID) async -> Bool { true } }4. Generic view (static dispatch, no any)
struct RecipeList<VM: RecipeListViewModelProtocol>: View { @State var vm: VM var body: some View { List(vm.recipes) { recipe in Text(recipe.title) } .overlay { if vm.isLoading { ProgressView() } } .task { await vm.fetch() } } }5. Multi-platform PreviewProvider
struct RecipeList_Previews: PreviewProvider { static var vm = RecipeListViewModelMock() static var previews: some View { Group { RecipeList(vm: vm) .previewDevice(PreviewDevice(rawValue: "iPhone 17 Pro")) .previewDisplayName("iPhone") RecipeList(vm: vm) .previewDevice(PreviewDevice(rawValue: "iPad Pro 11-inch (M5)")) .previewDisplayName("iPad") RecipeList(vm: vm) .previewDevice(PreviewDevice(rawValue: "My Mac")) .previewDisplayName("Mac") } } }Questions
Is protocol: AnyObject, Observable + generic view (struct RecipeList<VM: RecipeListViewModelProtocol>) the recommended approach in SwiftUI 6 / iOS 26?
Should the mock live at the ViewModel layer or the Repository layer for Xcode Previews? The alternative to the mock VM above would be injecting a mock repository into the real VM:
@Observable final class RecipeRepositoryMock: RecipeRepositoryProtocol { func fetchAll() async throws -> [Recipe] { Recipe.samples } func delete(id: UUID) async throws {} } // in the preview: static var previews: some View { RecipeList(vm: RecipeListViewModel(repository: RecipeRepositoryMock())) ... }This exercises the real VM's fetch/delete logic but requires the VM to be constructible in the preview. Is this the preferred approach?
How do generics propagate to child views? RecipeList is generic over VM, but child views like RecipeRow only need a Recipe value and have no dependency on the VM. Is passing the model directly sufficient, or does the generic constraint need to flow down? struct RecipeRow: View { let recipe: Recipe var body: some View { Text(recipe.title) } } // used inside RecipeList: List(vm.recipes) { recipe in RecipeRow(recipe: recipe) // no VM generic needed here? }#Preview doesn't seem to support rendering multiple devices simultaneously. Is PreviewProvider + .previewDevice() still the correct approach for side-by-side iPhone / iPad / Mac previews in iOS 26?
Any architectural red flags in the pattern above?
