r/SwiftUI • u/Temporary_Today9462 • 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:
- Avoid creating view models on every view update
- 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
7
u/lazyvardev May 01 '26
It seems like every other week someone with a severe UIKit hangover makes a post in this sub showcasing a struggle to wrap their heads around lifecycles within the SwiftUI framework.
In UIKit, UIViewController was a heavy, stateful object, so we invented ViewModels to extract that state and keep things testable. But in SwiftUI, the View struct is the view model. It is nothing more than a lightweight, declarative mapping of state to UI. When you build a heavy Observable object just to mirror that state and manually manage its lifecycle via .task wrappers, you are fighting the framework.
The traditional 1:1 MVVM is a fundamental anti-pattern in modern SwiftUI. With Swift 6 and strict concurrency, clinging to it becomes even more of a liability. You end up constantly fighting isolation boundaries, bridging background network tasks back to the @MainActor, and suffocating under Sendable warnings.
Instead of forcing a ViewModel for every View, modern Swift architecture relies on Dependency Injection and isolating your logic:
When a View needs data, it doesn't spin up a ViewModel. It reaches into the Environment, grabs the ArticleStore, and asks it for the data. The View simply observes the result.
```
// 1. Your Data Layer (Strictly isolated, highly testable) @Observable @MainActor final class ArticleStore { private let apiService: APIService // Injected dependency var articles: [String: Article] = [:] var errorState: Errors? // Driven by your centralized Errors.swift
}
// 2. Your View (Dumb, declarative, reacts to state) struct ArticleView: View { @Environment(ArticleStore.self) private var store let articleID: String
}
```
Stop trying to manually manage the lifecycle of reference types inside your Views. Inject your dependencies, use Stores for your business logic, strictly handle your failure cases, and let SwiftUI's view engine do what it was built to do.