Pedro Sousa
‹ ← Articles

Article · Blog

The @State you dumped in the View isn't a SwiftUI problem. It's a problem of where the decision lives.

Most architecture problems in SwiftUI don't start with the framework. They start when you decide where state lives without understanding what that decision is going to cost you six months from now. This isn't MVVM theory. It's what happens when the app grows and the @State was never built to grow with it.

The @State you dumped in the View isn't a SwiftUI problem. It's a problem of where the decision lives.

In a financial app I worked on, the transaction history screen had one @State for loading, one for errors, one for the transaction list, and two more for modal control. Five @State variables in a two-hundred-line View. The developer who wrote that wasn't bad. He was following the official Apple documentation examples.

The problem wasn't the code. It was that nobody had decided where state was supposed to live.

That seemingly small decision turned into technical debt in production in less than four months.


What the documentation doesn't tell you

SwiftUI makes @State too easy to use. That's intentional. Apple wants you to build interfaces fast, with minimal boilerplate. And it works. For prototypes, it works really well.

The problem starts when you take that same prototyping pattern into an app that's going to grow, gain more developers, more business rules, and more interdependent states.

@State is View-local state. The name says so. But the common practice is to use @State for anything that needs to react to change, regardless of whether that thing is actually local or not.

When you put something in @State that isn't the View's decision to make, the View starts making decisions that aren't its to make. And that's where the problem begins... one you won't see in the next sprint, but you'll definitely see by the third.


The mistake that keeps repeating

I've worked in SwiftUI codebases since it arrived, still unstable, on iOS 13. I've seen different teams make the same mistake in different ways.

The most common pattern: the ViewModel exists, but state leaks into the View.

struct CheckoutView: View {
    @StateObject var viewModel: CheckoutViewModel
    @State private var isConfirmationVisible = false
    @State private var selectedPaymentIndex = 0
    @State private var isProcessing = false

    var body: some View { ... }
}

What looks organized on first read becomes a problem when isProcessing needs to be synced with the ViewModel, selectedPaymentIndex influences validation logic, and isConfirmationVisible depends on the result of an async call.

The View starts orchestrating logic. And logic that lives in the View has no unit tests.

There's no way to test isProcessing without instantiating the entire View. No way to test the state sequence without going through the SwiftUI lifecycle. The code works, but it's opaque.


The real problem behind the problem

This isn't about @State being wrong. It's about responsibility boundaries.

SwiftUI invites developers to mix layers because it lowers the cost of doing so. You don't need a delegate, you don't need a closure, you don't need explicit binding. It's $variable and you're done. That low cost creates a trap: the ease of mixing hides the future price of separating.

The practical consequence I've seen most often: two developers working in parallel on the same feature, each adding @State to solve their own piece, and by the end of the sprint the View had eleven state variables and nobody could say with certainty what the transition flow between them was.

That's not an exaggeration. It happened in a codebase with over forty Swift modules, where the team was good and the process was organized.


What actually works in practice

The rule I've adopted and stand by: if the state participates in business logic or is derived from an external call, it lives in the ViewModel. If the state is purely presentational and has no consequences outside the View, @State is the right place.

That sounds obvious written out like that. But the real line is more subtle.

"Show or hide a modal" sounds like presentation state. But if the modal only appears after a transaction is confirmed, the state that triggers the modal is a consequence of business logic. It should be in the ViewModel, with the View simply reacting to it.

// ViewModel
@Published var transactionState: TransactionState = .idle

// View
.sheet(isPresented: .constant(viewModel.transactionState == .confirmed)) {
    ConfirmationView()
}

Now you can test the state transition without touching the View. And when the design changes to use a banner instead of a modal, you change only the View. The logic stays intact.


The case of the @State that broke the payment flow

In a payment flow I reviewed, there was a @State private var isButtonDisabled = false that was being manually updated in three different places inside the View. The confirm button would be enabled or disabled depending on a combination of conditions: filled-in fields, selected payment method, and absence of error.

Each condition was being checked in a different place. One update came via a text field callback. Another via an onChange on a Picker. The third via inline validation result.

When the backend validation returned with a timeout error, the button ended up enabled when it shouldn't have been. The user could try to confirm a transaction with invalid data.

The bug wasn't a SwiftUI bug. It was distributed state with no clear owner.

The fix was moving all of the button's enable logic into a computed property on the ViewModel:

var isConfirmButtonEnabled: Bool {
    hasValidFields && hasSelectedPayment && transactionState != .loading
}

A single source of truth. Testable. No manual synchronization.


The honest trade-off

Moving state to the ViewModel has a cost. The boilerplate increases. You need more @Published, more explicit bindings, more structure for simple things.

For small, stable screens, that cost isn't worth it. An "about the app" screen with a @State to expand some text doesn't need a ViewModel. That would be genuine over-engineering.

The point is: the decision needs to be conscious. Not "I'm using @State because it's easier." But "I'm using @State because this state is genuinely local and will never participate in any logic."

That distinction feels small right now. On a team of five people after six months of development, it's the difference between a codebase you can maintain and one you're afraid to touch.


What I would do differently today

I would have defined that boundary rule explicitly with the team from day one. I wouldn't have left it to emerge organically. Implicit conventions don't survive team turnover.

And I would have adopted snapshot tests for Views earlier. Not as a substitute for ViewModel unit tests, but as a visual regression detector. When you know the View is going to be tested by screenshot, you think twice before putting logic inside it.


The conclusion that sticks

SwiftUI doesn't have a state problem. It has a decision problem.

The framework is powerful precisely because it reduces friction. But zero friction also means you don't notice when you're crossing a boundary that's going to cost you later.

@State in the right View, in the right amount, is elegant. @State as a substitute for architecture is the hardest kind of technical debt to refactor... because it doesn't show up in lint, doesn't break the build, and doesn't fail in tests. It only shows up when the product grows and the team discovers that nobody knows who owns the state anymore.

And at that point, refactoring is no longer a technical decision. It's a negotiation with the roadmap.