Bad practice creating a StateObject


You’re more of a video kind of person? I’ve got you covered! Here’s a video with the same content than this article 🍿


Can you guess what’s wrong with this code?

We’re using the property wrapper @StateObject to store a view model:

But because the view model needs an argument to be initialized, we can’t give it its initial value directly at its declaration.

Instead, we have to do it in the initializer of the view:

To do so, we first create the view model:

Then we put it in a StateObject wrapper:

And finally we assign it to the underlying variable of the property wrapper:

And you might have noticed that because we want to avoid long lines of code, we’ve stored the intermediary result in a local variable.

But as it turns out, in this precise situation, storing the intermediate result in a local variable is actually a mistake that opens the door for very subtle bugs!

That might seem confusing, but let me explain!

To understand what’s happening, we need to take a look at the signature of the StateObject wrapper initializer:

As you can see, its argument is annotated with @autoclosure:

This information wasn’t visible at the call site, but it’s really important!

What @autoclosure means is that the argument that we pass will be automatically wrapped inside a closure.

And then the StateObject wrapper will make sure that this closure is only called once during the entire lifetime of the view.

This is really important, because this is what guarantees that our view model won’t be recreated every time that the view gets updated.

Now that we’ve understood this, let’s go back to our call site:

Since we’re only passing our local variable to the initializer of StateObject, it means that the StateObject wrapper won’t be able to manage the code that instantiates the view model itself.

As a result, every time that the initializer of the view is executed, a new view model will be created.

But since the StateObject initializer makes sure to execute the closure only once during the entire lifetime of the view, all these extra view models will never be stored inside the StateObject and instead will be immediately released from memory.

This means that in the best scenario, this code is being wasteful by instantiating view models which will never be used.

And in the worst scenario, if the initializer of the view model has any side effects, these side effects will be executed more than they should, which can lead to very subtle bugs.

So remember: whenever you need to instantiate a StateObject wrapper yourself, make sure that you pass the full initialization code as its argument!

That’s all for this article, I hope that you’ve enjoyed learning about this very tricky behavior of StateObject!

Here’s the code if you want to experiment with it:

// Before
import SwiftUI

struct MovieDetailsView: View {
    
    @StateObject var viewModel: MovieDetailsViewModel
    
    init(movie: Movie) {
        let viewModel = MovieDetailsViewModel(movie: movie)
        _viewModel = StateObject(wrappedValue: viewModel)      
    }
    
    var body: some View {
        // ...
    }
}

// After
import SwiftUI

struct MovieDetailsView: View {
    
    @StateObject var viewModel: MovieDetailsViewModel
    
    init(movie: Movie) {
        _viewModel = StateObject(
            wrappedValue: MovieDetailsViewModel(movie: movie)
        )      
    }
    
    var body: some View {
        // ...
    }
}
Previous
Previous

How to add a paywall with a single line of SwiftUI code 🛍️

Next
Next

How to help someone get started with iOS engineering 👩🏽‍💻👨🏻‍💻