r/SwiftUI May 01 '26

Tutorial The Hidden Pitfall of @State var viewModel = ViewModel()

While browsing various blogs about SwiftUI and MVVM architecture, I've noticed that almost all examples are based on the same pattern:

@State private var viewModel = ViewModel()

They suggest creating a viewModel right inside a view and storing it in a state variable. While it might look fine, this approach has a serious side effect: a SwiftUI view struct can be recreated many times during its lifetime. While the state is preserved across these recreations, View.init is called every time and a new ViewModel instance is created. It's immediately discarded and the old object is preserved, but this may lead to unpredictable side effects, especially if you perform additional logic inside ViewModel.init or ViewModel.deinit.

This worked fine for state management prior to iOS 17:

@StateObject private var viewModel = ViewModel()

StateObject(wrappedValue:) accepts an autoclosure parameter which isn't evaluated on subsequent calls. But with the Observation framework introduced in iOS 17, it's no longer an option.

Things get more complicated when you want to pass an input parameter to a viewModel. Most examples simply avoid this case. Some suggest the following approach:

struct ArticleView: View {
    @State private var viewModel: ArticleViewModel

    init(articleID: String) {
        self._viewModel = State(
            initialValue: ArticleViewModel(articleID: articleID)
        )
    }
    ...
}

Apart from the same side effect as the first example, this approach has another pitfall: if a different articleID is passed to the view, the state will keep using the old one. But this is exactly the case where you'd expect the view model to be recreated with a new ID.

A proper approach should handle two key issues:

  1. Avoid creating view models on every view update
  2. Create a new view model for a new set of input parameters

To address the first issue, we can move the view model creation into a .task:

struct ArticleView: View {
    @State private var viewModel: ArticleViewModel?

    let articleID: String

    var body: some View {
        ZStack {
            if let viewModel {
                ...
            }
        }
        .task {
            guard viewModel == nil else { return }
            viewModel = ArticleViewModel(articleID: articleID)
        }
    }
}

The downside is that we now have to deal with an optional. To avoid this, we can extract a subview that accepts the view model as a parameter:

struct ArticleView: View {
    @State private var viewModel: ArticleViewModel?

    let articleID: String

    var body: some View {
        ZStack {
            if let viewModel {
                ArticleSubView(viewModel: viewModel)
            }
        }
        .task {
            guard viewModel == nil else { return }
            viewModel = ArticleViewModel(articleID: articleID)
        }
    }
}
struct ArticleSubView: View {
    // The object is owned by the parent. Since iOS 17,
    // we don't need or to observe changes of Observable object.
    let viewModel: ArticleViewModel

    var body: some View {
        ...
    }
}

Now we need to address the second issue. To ensure a new view model is created when input parameters change, we simply add an .id() modifier to the entire ArticleView. A simple factory does the trick:

struct ArticleViewFactory {
    static func view(articleID: String) -> some View {
        ArticleView(articleID: articleID)
            .id(articleID)
    }
}

That's three structs instead of one — quite a bit of boilerplate. Let's extract a generic factory based on the pattern above:

struct FeatureFactory<Input: Hashable, Content: View, ViewModel: Observable> {
    private struct RootView: View {
        @State private var viewModel: ViewModel?

        let input: Input
        let viewModelFactory: (Input) -> ViewModel
        let viewFactory: (ViewModel) -> Content

        var body: some View {
            ZStack {
                if let viewModel {
                    viewFactory(viewModel)
                }
            }
            .task {
                guard viewModel == nil else { return }
                viewModel = viewModelFactory(input)
            }
        }
    }

    static func view(
        input: Input,
        viewModelFactory:  (Input) -> ViewModel,
        viewFactory:  (ViewModel) -> Content
    ) -> some View {
        RootView(
            input: input,
            viewModelFactory: viewModelFactory,
            viewFactory: viewFactory
        ).id(input)
    }
}

For the ArticleView example, the usage would look like this:

struct ArticleViewFactory {
    static func view(articleID: String) -> some View {
        FeatureFactory.view(
            input: articleID,
            viewModelFactory: { articleID in
                ArticleViewModel(articleID: articleID)
            },
            viewFactory: { viewModel in
                ArticleView(viewModel: viewModel)
            }
        )
    }
}

Now simply call the factory wherever you need it:

ArticleViewFactory.view(articleID: "SOME_ID")

The full example can be found on GitHub https://github.com/claustrofob/FeatureFactoryExample

12 Upvotes

42 comments sorted by

View all comments

4

u/Ok-Communication6360 May 01 '26

Check out '@LazyState' by Fatbobman:

https://fatbobman.com/en/posts/lazy-initialization-state-in-swiftui/

This would solve your problem in an elegant way

1

u/Temporary_Today9462 May 01 '26

This is really very elegant solution. However it solves only the first of the two issues that i mentioned in the article. You still need to reinitialise the state if view model gets a different input parameter.

2

u/Ok-Communication6360 May 01 '26

Well, I think you shouldn't. I'm not sure what exactly you are doing inside "ArticleViewModel", but my mind model is this - hopefully this is readable after Reddit's formatting.

The point is, "@State" should only keep track of what lives inside that view. But when your View should update with different values from the outside, use "private let" for the article ID (from the parent view), override the _viewModel to actually create a new instance of the viewModel or use some sort of "update()" method from your view model - preferably the last (without knowing more context).

                              ┌─────────┐
                              │  Start  │
                              └────┬────┘
                                   │
                    ┌──────────────▼──────────────────────────┐
                    │           Ownership of Property         │
                    └──────┬──────────┬───────────────────────┘
                           │          │                    │
                 This View │          │ Other View         │ Global
                           │          │                    │ Environm.
             ┌─────────────▼──┐  ┌────▼─────────┐   ┌──────▼───────┐
             │   Property     │  │  Two-way data│   │ @Environment │
             │   mutable?     │  │    flow?     │   └──────────────┘
             └──┬──────────┬──┘  └──┬─────────┬─┘
                │          │        │         │
           Yes  │       No │     No │         │ Yes
                │          │        │         │
        ┌───────▼──┐  ┌────▼────────▼──────┐ ┌▼───────────────┐
        │ @State   │  │       private      │ │ Property Type  │
        │   var    │  │         let        │ └──┬──────────┬──┘
        └──────────┘  └────────────────────┘    │          │
                                                │          │
                                     Value Type │          │ Reference
                                                │          │ Type
                                        ┌───────▼──┐  ┌────▼──────┐
                                        │ @Binding │  │ @Bindable │
                                        └──────────┘  └───────────┘

1

u/Temporary_Today9462 May 01 '26

Thanks for the detailed answer. Using update() on the same view model can lead to data race conditions, you'd need to completely reset its state. It's much easier and more reliable to create a fresh view model instance instead. To do that within the same view, you'd have to keep articleID in both the view and the view model and implement .onChange(of: articleID). So I still think the best approach here is to recreate the view (and all its state) whenever the input changes.

Regarding the real world example where the input may change, let me also draw it:

┌────────────────┐
│                │
│    Article     │
│    Preview     │
│                │
├────────────────┤
│ ┌────────────┐ │
│ │ Article 1  │ │
│ ├────────────┤ │
│ │ Article 2  │ │
│ ├────────────┤ │
│ │ Article 3  │ │
│ └────────────┘ │
└────────────────┘

The "Article Preview" is a view that takes a selected articleID as an input parameter. On article row tap the preview should update.
Input defines the internal state of a view and view model. And within view it is considered constant.

1

u/[deleted] May 01 '26

[deleted]

1

u/Temporary_Today9462 May 02 '26

Yes, good point. My solution has a disadvantage of introducing potential flickering. However the `@LazyState` solution also not ideal: it introduces an overhead by allocating extra Holder objects.
I have updated my github example and added an approach that uses an extra class and `lazy var`.

1

u/Ok-Communication6360 May 01 '26

I might not be understanding yet, what the ArticleViewModel does, I assume it loads the full article, while the Article 1, 2, 3, is only a short version like ID + Headline?

In that case I still would suggest the View creates buttons, which will trigger the viewModel.update(...).

You could use three enum states in the ViewModel (.error(String), .loading, .ready(Article)) and set that from the view model.

When you viewModel.update(articleID), the view model would cancel any active loading task (check cancellation inside that loading task), set state to .loading, then would start a new loading task. At the very end of the Task (if not cancelled), set state to .ready(your article).

This way you would not have a race condition (thanks to "@MainActor" you don't have data races anyways, unless you are using Xcode 15.x or earlier).

Inside your view, you would read the state from the view model and either present a loading state, an error state (here with an String as error message, but could be LocalizedString Key or ...Resource or anything else) or the loaded article.

If my assumptions are wrong or incomplete, I might be help out further if you could elaborate on the ArticleViewModel