This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We investigate the problem of creating and updating view models in SwiftUI and outline a potential solution.

00:06 Today we want to solve the view model problem — although, we shouldn't overpromise. What is the view model problem, anyway?

00:18 When we use a view model in SwiftUI, it's currently pretty inconvenient. We often want to create the view model inside the view so that the view model's lifetime is tied to the view's lifetime, but the view model usually depends on properties that are passed into the view. For example, imagine we have a UserViewModel and a UserView, and the user view receives a name or an ID. Creating the view model itself is easy, but making sure the view model updates when a different name is passed in is harder. In a split view, for example, the view might stay alive while the selection changes, and the view just receives a new name. In that case, we need extra logic to update the view model, and it's very easy to forget to do that.

A Simple Reproduction

01:05 We can demonstrate this pretty easily. Let's create a user view model and make it Observable. It can store a name, which we set in the initializer, and maybe also track how often the user clicks on something:

import SwiftUI
import Observation

@Observable
class UserViewModel {
    var name: String
    var clicks = 0

    init(name: String) {
        self.name = name
    }
}

01:38 Next, we create a UserView. In the body, we want to display the name from the view model. We add a viewModel property and we render viewModel.name:

struct UserView: View {
    var viewModel: UserViewModel

    var body: some View {
        Text(viewModel.name)
    }
}

01:58 In ContentView, we create a UserView and initialize the view model with a name:

struct ContentView: View {
    var body: some View {
        VStack {
            UserView(viewModel: .init(name: "Chris"))
        }
        .padding()
    }
}

02:16 This shows "Chris," and everything looks fine. Now let's add a state property, for example a counter, and a Stepper to control the state:

struct ContentView: View {
    @State var value = 0

    var body: some View {
        VStack {
            UserView("Chris")
            Stepper("Counter: \(value)", value: $value)
        }
        .padding()
    }
}

02:32 Every time we change the counter, SwiftUI invalidates the state, recomputes the body, and that means our view model initializer is called again. We can verify this by adding a print statement:

@Observable
class UserViewModel {
    var name: String
    var clicks = 0

    init(name: String) {
        self.name = name
        print("UserViewModel init")
    }
}

02:59 When we run this, we see the initializer printing every single time. That happens because we're creating the view model directly inside the ContentView body, so it runs on every recomputation. Let's refactor this. Instead of passing in a view model, we pass just a name to UserView. Inside UserView, we assign create a view model with the name:

struct UserView: View {
    @State var viewModel: UserViewModel
    
    init(name: String) {
        self.viewModel = UserViewModel(name: name)
    }
    
    var body: some View {
        Text(viewModel.name)
    }
}

struct ContentView: View {
    @State var value = 0
    
    var body: some View {
        VStack {
            UserView(name: "Chris")
            Stepper("Counter: \(value)", value: $value)
        }
        .padding()
    }
}

03:32 Let's run this again and clear the console. The initializer still runs multiple times. That's because we're now creating the view model in the initializer of UserView, but that initializer is still executed every time the body of ContentView is recomputed.

03:54 There's another problem hiding here as well. If we start changing what we pass in, things get even worse.

Losing State on Updates

04:07 We already added a clicks property to the user view model. Let's actually use that. Inside UserView, we can add another Stepper that modifies the click count. We wrap everything in a VStack, add the stepper, and bind it to viewModel.clicks. A Stepper needs a binding, so we need a bindable view model. Since the model is observable, we can try using @Bindable:

struct UserView: View {
    @Bindable var viewModel: UserViewModel
    
    init(name: String) {
        self.viewModel = UserViewModel(name: name)
    }
    
    var body: some View {
        VStack {
            Text(viewModel.name)
            Stepper("User clicks \(viewModel.clicks)", value: $viewModel.clicks)
        }
        .fixedSize()
    }
}

05:22 Now we have two steppers. We can tap the inner one and change the number of clicks in the view model. But as soon as we change the outer counter, the clicks reset. That's because we're recreating the view model again.

05:47 Since we're dealing with an Observable object, we can also store the view model in @State instead of @Bindable, and SwiftUI will manage its lifetime for us, persisting the model across view updates:

struct UserView: View {
    @State var viewModel: UserViewModel
    // ...
}

06:16 Now the behavior looks much better. When we change the outer counter, the clicks inside the user view are preserved because the view model is preserved.

06:55 If we open the console, we can see something interesting. The view model initializer is still being called when the outer counter changes, but the new instance isn't actually used. SwiftUI keeps using the existing state value, and the newly created instance is ignored as long as the view remains in the view hierarchy.

The Real Problem

07:31 So far so good, but now we run into the next issue. We're passing data into the view via the initializer, in this case the name. Let's simulate a typical sidebar-detail setup. We add another state property, maybe a Boolean toggle, and we switch between two different users based on that toggle:

struct ContentView: View {
    @State var value = 0
    @State var toggle = false
    
    var body: some View {
        VStack {
            UserView(name: toggle ? "Florian" : "Chris")
                .border(.blue)
            Stepper("Counter: \(value)", value: $value)
            Toggle("Switch user", isOn: $toggle)
        }
        .padding()
    }
}

08:42 When we flip the toggle, we'd expect the name to change, but it doesn't. The steppers still work, but we can't switch the user being shown. The UserView stays on screen and therefore it keeps its existing view model state.

09:15 In the initializer of UserView, we're not assigning to the state itself, we're only providing the initial value for the state property. That initial value is used once, when the state storage is created. After that, the right-hand side of the assignment is ignored:

struct UserView: View {
    @State var viewModel: UserViewModel
    
    init(name: String) {
        self.viewModel = UserViewModel(name: name)
    }
    // ...
}

09:34 We have to remember that the View struct is just a blueprint; it's not part of the actual view tree. Since the state is tied to the position in the view tree, we can't assign anything to the runtime state used to populate the values onscreen.

09:56 This is a real problem for reusable views. If we ship UserView in a library, callers expect that passing in a different name recreates the view model. By not doing so, we're breaking a fundamental expectation of SwiftUI: views should update when their input changes. Solving this isn't hard, but it requires some diligence.

Recreating the View Model Explicitly

10:42 One solution is to observe changes to the input, and recreate the view model when it changes. We can use onChange(of:) to observe the passed-in name and assign a new view model:

struct UserView: View {
    @State var viewModel: UserViewModel
    var name: String
    
    init(name: String) {
        self.name = name
        self.viewModel = UserViewModel(name: name)
    }
    
    var body: some View {
        VStack {
            Text(viewModel.name)
            Stepper("User clicks \(viewModel.clicks)", value: $viewModel.clicks)
        }
        .onChange(of: name) {
            self.viewModel = UserViewModel(name: name)
        }
        .fixedSize()
    }
}

11:26 Now it behaves correctly. It shows the name "Chris," we can increment the counters, and changing the outer counter doesn't affect the inner state. When we toggle the user to "Florian," the view model is recreated as expected. The initializer still runs frequently, so it's inefficient, but the view now functionally works.

11:56 The problem is that this approach doesn't scale. If UserViewModel had many properties, like a first name, a last name, an address, an age, and a date of birth, we'd need a separate onChange(of:) for each one. Every parameter of the view model's initializer needs to be observed.

12:27 You can think of this initializer almost like a closure. If the closure captures a property of the view, we need to observe changes to that property. This is similar to observation problems we had in UIKit. It's easy to forget an onChange(of:), and when that happens, the view appears to work, but it's subtly broken. Whether or not it becomes a real issue depends on the UI. In this example, everything seems fine until we try to switch users.

13:33 Another approach is to create the view model outside the view, somewhere in a model layer, and pass it in. But then we need to manage its lifetime manually, e.g. the parent view has to decide when to create and destroy the view model. There's no single, clean solution to this problem in SwiftUI today.

Thinking of an Abstraction

14:14 The idea is to create an abstraction that reduces how much manual work we have to do. We can take inspiration from SwiftUI itself.

14:33 Instead of passing in a view model directly, we could pass in something that knows how to create a view model, like a closure:

struct UserView: View {
    var viewModel: () -> UserViewModel
    // ...
}

14:49 The issue with closures is that we can't compare them. SwiftUI has a similar challenge. Views are almost like closures: they take inputs and calling the body produces a result. But because views are structs with stored properties, SwiftUI can compare them for equality. That's sometimes described using terms like defunctionalization or reification.

15:24 In short, let's think about a SwiftUI View body as a function that closes over the view's properties. And the struct representation lets us inspect the inputs of that function, whereas closures don't give us that ability.

15:55 What we want is something that can generate a view model and that we can also compare. One idea is to introduce a separate type that represents the initializer for the view model. That type would store all the initializer parameters. For example, we could define a UserViewModelInit that stores the name, and later also an age if needed:

struct UserViewModelInit {
    var name: String
    var age: Int
}

16:28 If we pass this struct into UserView, we can compare it and hook it up so that a new view model is created whenever the input changes. We could automate a lot of these mechanics, making it much harder to get this wrong.

16:50 The struct above would have a method like make() that creates a view model by calling the real initializer:

struct UserViewModelInit {
    var name: String

    func make() -> UserViewModel {
        UserViewModel(name: name)
    }
}

17:09 This still isn't a complete solution, because we still need to store the view model and react to changes. But it's a step in the right direction.

Desired API

17:23 We're not going to finish this today, but it's useful to think about what we want the final API to look like. One idea is to introduce a protocol that view models conform to, and we could write a custom property wrapper that stores the view model.

18:17 So, instead of writing @State, we might write something like @ViewModel. The view would declare a view model property using that wrapper, and the view model's parameters would be passed in from the outside:

struct UserView: View {
    @ViewModel var viewModel: UserViewModel // = UserViewModelInit(name: "Chris")
    // ...
}

18:36 It's not clear yet whether this is fully possible, we might have to rely on some undocumented APIs. But that's the general direction. We'll see how much of this we can make work next time.

Resources

  • Sample Code

    Written in Swift 6.2

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

209 Episodes · 72h58min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes