swiftswiftuimvvmdependency-injection

Understanding Dependency Injection in SwiftUI to avoid duplicate instance recreation on view redraws


I think I have a general misunderstanding of how to work with dependencies in SwiftUI considering frequent view redraws, not just in view body but also in navigation methods.

The code below seems like a logical way to navigate to a new module with its dependencies:

.navigationDestination(item: self.$viewModel.selectedUserId) { selectedUserId in
                ProfileView(viewModel: ProfileViewModel(userId: selectedUserId))
}

But, the .navigationDestination method is triggering multiple times here (3 times to be precise), creating duplicate dependencies. The init(viewModel: ProfileViewModel) {self._viewModel = StateObject(wrappedValue: viewModel)} pattern doesn't help because we're creating a new view instance every time. And to my understanding this is by design, even though other navigation methods and older API might trigger only once, you shouldn't really count on it because it's a part of SwiftUI views internal behavior. I know there're many solutions to this specific problem, I've made different work arounds by referring to other navigation methods that only trigger once, tried all kinds of boolean checks and dependency caching, environment injection, factory/assembly patterns etc., but I still fail to understand the general principle of working with dependencies in swiftUI, to make it all clean and separated. Is view supposed to store all the child and navigated views? Or just their dependencies?

What's the recommended way of working with external dependencies for navigated/child views inside SwiftUI to avoid duplicate instances on view redraws?


EDIT: changed the example code below to show what I'm trying to achieve.

struct ChatsListContainerView<ViewModel: ChatsListViewModelProtocol, CommunityView: View>: View {
    @ObservedObject var viewModel: ViewModel
    
    let communityViewBuilder: (CommunityInputData) -> CommunityView
    
    var body: some View {
        NavigationStack {
            ChatsListView(viewState: self.viewModel.viewState, actionHandler: self.viewModel.handleViewActions)
                .navigationTitle("Chats")
                .navigationDestination(item: self.$viewModel.communityDetailsInputData) { communityDetailsData in
                    // This is just any DI method that returns a view with configured dependencies
                    self.communityViewBuilder(communityDetailsData)
                }
        }
    }
}

// This is our assembly essentially
enum CommunityAssembly {
    static func makeModule(inputData: CommunityInputData) -> some View {
        let vm = CommunityViewModel(viewState: .loading, communityService: ServiceLocator.shared.communityService, chatsService: ServiceLocator.shared.chatsService, communityId: inputData.id)
        let view = CommunityContainerView(viewModel: vm)
        
        return view
    }
}

My question is where do I create these external dependencies (view model with its services) after navigation triggers to guarantee they always get instantiated once?


Solution

  • (Note I had to slightly change the names of the structs since I already had structs with the same names in my demo project).

    Is view supposed to store all the child and navigated views?

    View should describe what to render, not do things (like create ProfileViewModel inline). So you should avoid introducing side effects in the body (any observable changes that happen outside the local scope or affect something beyond just returning a value - like printing to the console or instantiating models).


    A .navigationDestination can evaluate its closure multiple times during navigation prep:

    ... and maybe other scenarios.

    In this case, the closure seems to be evaluated three times, which is why you see the output .navigationDestination trigger.

    Furthermore, calling ProfileViewModel(userId: selectedUserId) inside the .navigationDestination closure basically creates multiple instances (one instance every time the closure is evaluated) before one is even used for the actual navigation. Actually, this would also apply if the destination View created ProfileViewModel instances in its init.


    Unless I am missing something, I don't see why you need to create a ProfileViewModel at all when you already have access to the selected user object. Instead of setting a selectedUserID, you could set a selectedUser and pass the user to NavigationProfileView.

    Here's a full example with this approach:

    import SwiftUI
    
    struct NavigationUser: Identifiable, Hashable {
        let id: String
        let name: String
    }
    
    class UsersListViewModel: ObservableObject {
        // @Published var selectedUserId: String?
        @Published var selectedUser: NavigationUser?
        
        let users = [
            NavigationUser(id: "1", name: "John"),
            NavigationUser(id: "2", name: "Jane"),
            NavigationUser(id: "3", name: "Bob"),
            NavigationUser(id: "4", name: "Bill"),
            NavigationUser(id: "5", name: "Doug")
        ]
    }
    
    class ProfileViewModel: ObservableObject {
        @Published var userId: String
        
        deinit {
            print("❌ \(String(describing: type(of: self))) destroyed")
        }
        
        init(userId: String) {
            print("✅ \(String(describing: type(of: self))) created")
    
            self.userId = userId
        }
    }
    
    struct NavigationContentViewDemo: View {
        @StateObject private var viewModel = UsersListViewModel()
        
        var body: some View {
            
            NavigationStack {
                List(viewModel.users) { user in
                    Button(action: {
                        // viewModel.selectedUserId = user.id
                        viewModel.selectedUser = user
                    }) {
                        HStack {
                            Image(systemName: "person.circle")
                                .foregroundColor(.blue)
                            Text(user.name)
                                .foregroundColor(.primary)
                            Spacer()
                            Image(systemName: "chevron.right")
                                .foregroundColor(.gray)
                                .font(.caption)
                        }
                    }
                }
                .navigationDestination(item: $viewModel.selectedUser) { selectedUser in
                    NavigationProfileView(user: selectedUser )
                }
            }
        }
    }
    
    struct NavigationProfileView: View {
        
        let user: NavigationUser
        
        var body: some View {
            let _ = print("Display profile for user: \(user.name)")
            Text("User: \(user.name)")
        }
    }
    
    #Preview {
        NavigationContentViewDemo()
    }
    

    If you really need a ProfileViewModel, here's an example using @Observable, where you still pass a user as parameter, but instantiate the view model in the .onAppear of the NavigationProfileView:

    import SwiftUI
    
    struct NavigationUser: Identifiable, Hashable {
        let id: String
        let name: String
    }
    
    @Observable
    class UsersListViewModel {
        var selectedUser: NavigationUser?
        
        let users = [
            NavigationUser(id: "1", name: "John"),
            NavigationUser(id: "2", name: "Jane"),
            NavigationUser(id: "3", name: "Bob"),
            NavigationUser(id: "4", name: "Bill"),
            NavigationUser(id: "5", name: "Doug")
        ]
    }
    
    @Observable
    class ProfileViewModel {
        var userId: String
        
        deinit {
            print("❌ \(String(describing: type(of: self))) for userId: \(userId) destroyed")
        }
        
        init(userId: String) {
            print("✅ \(String(describing: type(of: self))) for userId: \(userId) created")
    
            self.userId = userId
        }
    }
    
    struct NavigationContentViewDemo: View {
        
        @State private var viewModel = UsersListViewModel()
        
        var body: some View {
            
            NavigationStack {
                List(viewModel.users) { user in
                    Button(action: {
                        viewModel.selectedUser = user
                    }) {
                        HStack {
                            Image(systemName: "person.circle")
                                .foregroundColor(.blue)
                            Text(user.name)
                                .foregroundColor(.primary)
                            Spacer()
                            Image(systemName: "chevron.right")
                                .foregroundColor(.gray)
                                .font(.caption)
                        }
                    }
                }
                .navigationDestination(item: $viewModel.selectedUser) { selectedUser in
                    NavigationProfileView(user: selectedUser )
                }
            }
        }
    }
    
    struct NavigationProfileView: View {
        
        //Parameters
        let user: NavigationUser
        
        //View model
        @State private var viewModel: ProfileViewModel?
        
        //Body
        var body: some View {
            
            // let _ = print("Display profile for user: \(user.name)") // <- If you uncomment this, it will print twice in this case, because the body will have to render twice due to the unwrapping of the optional state/viewModel.
            
            VStack {
                //Safely unwrap the optional viewModel
                if let viewModel = viewModel {
                    Text("User ID: \(viewModel.userId)")
                }
            }
            .onAppear {
                viewModel = ProfileViewModel(userId: user.id)
                print("Display profile for user: \(user.name)") // <- This will print only once
            }
        }
    }
    
    #Preview {
        NavigationContentViewDemo()
    }
    

    UPDATE 1:

    Based on the discussion in the comments, here's another example, with a two tabs setup and an additional navigation layer that navigates away from the selected profile.

    The .onAppear of the profile view now checks if the viewModel is nil before creating a viewModel instance.

    If you change tabs and change back, the navigation state is maintained, as well as the viewModel state.

    import SwiftUI
    
    struct NavigationUser: Identifiable, Hashable {
        let id: String
        let name: String
    }
    
    @Observable
    class UsersListViewModel {
        
        var selectedUser: NavigationUser?
        
        let users = [
            NavigationUser(id: "1", name: "John"),
            NavigationUser(id: "2", name: "Jane"),
            NavigationUser(id: "3", name: "Bob"),
            NavigationUser(id: "4", name: "Bill"),
            NavigationUser(id: "5", name: "Doug")
        ]
    }
    
    @Observable
    class ProfileViewModel {
        var userId: String
        
        deinit {
            print("❌ \(String(describing: type(of: self))) for userId: \(userId) destroyed")
        }
        
        init(userId: String) {
            print("✅ \(String(describing: type(of: self))) for userId: \(userId) created")
    
            self.userId = userId
        }
    }
    
    struct NavigationContentViewDemo: View {
        
        @State private var viewModel = UsersListViewModel()
        @State private var selectedProfileViewModel: ProfileViewModel?
        
        var body: some View {
            
            TabView {
                
                //Home tab
                Text("Home Tab")
                    .tabItem {
                        Label("Home", systemImage: "house")
                    }
            
                //Users tab
                NavigationStack {
                    List(viewModel.users) { user in
                        Button(action: {
                            viewModel.selectedUser = user
                        }) {
                            HStack {
                                Image(systemName: "person.circle")
                                    .foregroundColor(.blue)
                                Text(user.name)
                                    .foregroundColor(.primary)
                                Spacer()
                                Image(systemName: "chevron.right")
                                    .foregroundColor(.gray)
                                    .font(.caption)
                            }
                        }
                    }
                    .navigationDestination(item: $viewModel.selectedUser) { selectedUser in
                        NavigationProfileView(user: selectedUser )
                    }
                }
                .tabItem {
                    Label("Users", systemImage: "person.3.fill")
                }
            }
        }
    }
    
    struct NavigationProfileView: View {
        
        //Parameters
        let user: NavigationUser
        
        //View model
        @State private var viewModel: ProfileViewModel?
        
        //Body
        var body: some View {
            
            VStack {
                //Safely unwrap the optional viewModel
                
                Text("Profile for: \(user.name)")
                
                if let viewModel = viewModel {
                    Text("User ID: \(viewModel.userId)")
                    
                    NavigationLink {
                        Text("Some other view for userID: \(viewModel.userId)")
                    } label: {
                        Text("Navigate to some other view")
                    }
                }
            }
            .onAppear {
                if viewModel == nil {
                    viewModel = ProfileViewModel(userId: user.id)
                    print("Created profile view model for user: \(user.name)") // <- This will print only once
                }
                else {
                    print("viewModel is not nil")
                }
            }
        }
    }
    
    #Preview {
        NavigationContentViewDemo()
    }
    

    UPDATE 2:

    As a follow-up to your edited question...

    If you want to create the external dependencies AFTER the navigation triggers, the only options I am aware of are in .onAppear or .task of the view. This ensures that the navigation was successful and is probably most flexible from a UI/UX standpoint, since it allows showing a progress indicator as needed. So user taps to navigate, navigation happens right away, and if the models are expensive, progress views can be shown as needed.

    Otherwise, the alternative is to prepare the ProfileViewModel BEFORE navigation, as part of the navigation button action and then trigger navigation if the viewModel is created. This is in order to avoid side effects inside the `.a

    As mentioned above, this could introduce delays between the navigation button tap and the actual navigation, even if a progress view is shown (an example of this can be seen in iOS if you go to Settings > Apple account > Personal Information).

    Below is a working example of preparing the profile viewModel before navigation. This allows NavigationProfileView to be passed the viewModel as parameter, without using .onAppear and without causing multiple instantiations.

    I also included a profile child view as an additional navigation layer, that can also accept the same view model.

    Note, however, that this approach does NOT guarantee navigation, since the navigation can fail after the viewModel is created, for various reasons, like a misconfiguration in .navigationDestination or even a completely missing navigation destination altogether.

    import SwiftUI
    
    struct NavigationUser: Identifiable, Hashable {
        let id: String
        let name: String
    }
    
    enum NavigationProfileRoute: Hashable, Equatable {
        case profile
    }
    
    @Observable
    class UsersListViewModel {
        
        var navigationPath: [NavigationProfileRoute] = []
        var selectedProfileViewModel: ProfileViewModel?
        
        let users = [
            NavigationUser(id: "1", name: "John"),
            NavigationUser(id: "2", name: "Jane"),
            NavigationUser(id: "3", name: "Bob"),
            NavigationUser(id: "4", name: "Bill"),
            NavigationUser(id: "5", name: "Doug")
        ]
    }
    
    @Observable
    class ProfileViewModel {
        var userId: String
        
        deinit {
            print("❌ \(String(describing: type(of: self))) for userId: \(userId) destroyed")
        }
        
        init(userId: String) {
            print("✅ \(String(describing: type(of: self))) for userId: \(userId) created")
    
            self.userId = userId
        }
    }
    
    struct NavigationContentViewDemo: View {
        
        @State private var viewModel = UsersListViewModel()
        
        var body: some View {
            
            TabView {
                
                //Home tab
                Text("Home Tab")
                    .tabItem {
                        Label("Home", systemImage: "house")
                    }
            
                //Users tab
                NavigationStack(path: $viewModel.navigationPath) {
                    List(viewModel.users) { user in
                        Button(action: {
                            viewModel.selectedProfileViewModel = ProfileViewModel(userId: user.id)
                            
                            if let _ = viewModel.selectedProfileViewModel {
                                viewModel.navigationPath.append(.profile)
                            }
                        }) {
                            HStack {
                                Image(systemName: "person.circle")
                                    .foregroundColor(.blue)
                                Text(user.name)
                                    .foregroundColor(.primary)
                                Spacer()
                                Image(systemName: "chevron.right")
                                    .foregroundColor(.gray)
                                    .font(.caption)
                            }
                        }
                    }
                    .navigationDestination(for: NavigationProfileRoute.self) { route in
                        switch route {
                            case .profile:
                                if let profileViewModel = viewModel.selectedProfileViewModel {
                                    NavigationProfileView(viewModel: profileViewModel )
                                }
                        }
                    }
                }
                .tabItem {
                    Label("Users", systemImage: "person.3.fill")
                }
            }
        }
    }
    
    struct NavigationProfileView: View {
        
        //Parameters
        @Bindable var viewModel: ProfileViewModel
        
        //Body
        var body: some View {
            
            VStack {
                //Safely unwrap the optional viewModel
                
                Text("Profile for: \(viewModel.userId)")
                
                NavigationLink {
                    ProfileChildView(viewModel: viewModel)
                } label: {
                    Text("Additional details")
                }
            }
        }
    }
    
    struct ProfileChildView: View {
        
        //Parameters
        @Bindable var viewModel: ProfileViewModel
        
        //Body
        var body: some View {
            Text("Additional details for: \(viewModel.userId)")
        }
    }
    
    
    #Preview {
        NavigationContentViewDemo()
    }