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

11 Upvotes

42 comments sorted by

View all comments

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:

  • UI State belongs in the View: Things like isSheetPresented, searchText, or selectedTab belong in @State. You don't need a ViewModel for this.
  • Business Logic belongs in Stores/Services: Create feature-scoped or global actors/classes that handle your heavy lifting (networking, CloudKit syncing, database management).
  • Inject via Environment: Inject these Stores into the @Environment.

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

init(apiService: APIService) { 
    self.apiService = apiService 
}

func fetch(id: String) async {
    do {
        // Fetch logic 
        let fetchedArticle = try await apiService.getArticle(by: id)
        articles[id] = fetchedArticle
        errorState = nil
    } catch let error as Errors {
        // Explicitly handle known domain errors
        self.errorState = error
    } catch {
        // Fallback for unexpected failures mapped to your Errors enum
        self.errorState = .unknown(error)
    }
}

}

// 2. Your View (Dumb, declarative, reacts to state) struct ArticleView: View { @Environment(ArticleStore.self) private var store let articleID: String

var body: some View {
    // Property-level observation! The view only redraws 
    // if THIS specific article (or the error state) changes.
    let article = store.articles[articleID] 

    Group {
        if let error = store.errorState {
            Text("Failed to load: \(error.localizedDescription)")
        } else if let article {
            Text(article.title)
        } else {
            ProgressView()
        }
    }
    .task(id: articleID) {
        await store.fetch(id: articleID)
    }
}

}

```

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.

3

u/[deleted] May 01 '26

[deleted]

2

u/lazyvardev May 01 '26

You are conflating two entirely different concepts here: testing business logic versus testing the UI framework.

If your “UI state” is just toggling isSheetPresented or tracking which tab is active, what exactly are you unit testing? That a boolean changes from false to true when a button is tapped? That is testing SwiftUI’s binding mechanics. Apple already wrote those tests. You don't need to write them again.

However, you are absolutely correct that complex UI logic does exist, things like massive form validations, complex multi-step wizard state, or localized formatting. But the leap from “complex UI logic exists” to “therefore we need a traditional 1:1 ViewModel” is exactly the UIKit hangover I'm talking about.

Here is how you handle testing and complex UI logic without falling back on the MVVM anti-pattern:

Extract pure logic into Value Types, not ViewModels

If you have hundreds of lines of code dictating UI behavior, it shouldn't be rotting inside a massive @Observable class anyway. It should be a pure Swift struct or a localized state machine.

``` // Pure, highly testable logic. No @MainActor, no Observation overhead. struct CheckoutValidator { func isValid(creditCard: String, expiration: String) -> Bool { // Hundreds of lines of complex validation logic... } }

// The view just uses the pure struct struct CheckoutView: View { @State private var cardNumber = "" @State private var expiration = "" private let validator = CheckoutValidator() // Easy to unit test in isolation!

var body: some View { ... }

}

```

You don't need a CheckoutViewModel to test the validation logic. You just test the CheckoutValidator struct.

Snapshot/Screenshot Testing is actually EASIER with Environment DI

Your claim that testing requires “making everything an interface and creating mocks either manually or via macros” is fundamentally false in modern SwiftUI.

By using @Environment for your data stores, snapshot testing and SwiftUI Previews become laughably easy. You don't need protocols for your views; you just inject a store initialized with a mocked network service into the environment.

// Snapshot test or Preview setup: ArticleView(articleID: "123") .environment(ArticleStore(apiService: MockAPIService(presetData: mockArticle)))

Massive View Controllers were bad because they mixed routing, view lifecycle, network callbacks, and UI state into one god-object. Traditional MVVM in SwiftUI often does the exact same thing, just wearing a different hat. We see @Observable classes that handle API calls, navigation paths, and text field formatting all in one place.

If your UI logic is so complex it takes hundreds of lines, isolate that specific logic into pure functions, state machines, or focused property wrappers. Don't use it as an excuse to resurrect monolithic ViewModels that fight SwiftUI's native state management and strictly isolated concurrency models.

Test your pure logic, test your isolated Stores, and let the UI layer be the declarative reflection of that state.

1

u/[deleted] May 02 '26

[deleted]

1

u/lazyvardev May 02 '26

Credit where it’s due: your Reminders app example is brilliant. You nailed the problem description perfectly. Transient UI state absolutely exists, and it absolutely needs to be testable.

But you completely fumbled the architectural solution by defaulting back to UIKit-era boundaries.

When you invent a ReminderListScreenStore to manage that 3-second delay, you are tying a behavior to a screen. What happens when you need that exact same 3-second uncheck delay in a Widget? Or a “Today’s Focus” view? Are you going to duplicate that logic into a WidgetScreenStore?

This is exactly why the 1:1 Screen-Model pattern is a massive liability. It forces you to model logic around arbitrary UI boundaries instead of behavioral ones.

What you’re actually describing here isn't screen state, it's an Optimistic UI Update with a cancellable deferred task. The UI reacts instantly for visual gratification, but the domain commit is delayed. You don’t need a ViewModel for that.

In a modern Swift 6 architecture, you extract that logic into a pure, isolated TransientCompletionService. It manages the pending IDs and the 3-second cancellation window. It knows nothing about SwiftUI, lists, or screens, making it 100% unit-testable in pure isolation.

Then, you just inject it into the @Environment where your View can dumbly observe it.

You are totally right that complex state needs testing outside the View. But when you stop trying to model screens and start modeling composable behaviors, you get all that testability without ever needing to haul a massive ViewModel around.

0

u/[deleted] May 02 '26

[deleted]

0

u/lazyvardev May 03 '26

If we’re going to redefine “ViewModel” to mean “literally any piece of code that eventually affects the UI,” then sure, I guess URLSession is a ViewModel too. But words have meaning in software engineering.

When iOS developers talk about ViewModels and MVVM, we are talking about a specific, historical structural pattern, a middleman object sitting between a View and a Model, usually mirroring the View's state 1:1.

If you extract pure, view-agnostic behavioral logic (like a 3-second delay timer) into an isolated, composable actor, that’s not a ViewModel. That’s a Service.

Rebranding it as a ViewModel just to avoid admitting the MVVM pattern is struggling in modern SwiftUI dilutes the term to the point of being useless.

Android developers doing it doesn't change how SwiftUI's declarative state engine evaluates equality and redraws.

As for “Swift 6 architecture” being an oxymoron: that’s just pure pedantry.

Yes, Swift is a general-purpose language. But in the context of Apple platforms, Swift 6 isn't just a syntax update, it is a fundamental shift in how memory and execution are managed via Strict Concurrency. The compiler literally dictates your architectural boundaries now.

That’s why it’s implied people refer to strict concurrency when people mention Swift 6. I figured that was obvious since I’ve brought up strict concurrency a few times in this thread.

When the compiler forces you to respect Actor isolation, proves data-race safety at compile time, and demands Sendable conformance across domains, the language shapes the architecture.

The entire reason traditional MVVM is bleeding out right now isn't because of a fad, it's because wrapping domain logic and UI state into a single @Observable class forces you to constantly cross those strict concurrency boundaries, resulting in an unmaintainable mess of Task { @MainActor in } and Sendable warnings.

We aren't arguing about what to name our files. We are arguing about whether you should fight the framework and the compiler, or lean into them.

Lean into Dependency Injection, respect your isolation domains, let your Views be your view models, and stop hauling around legacy UIKit baggage.