Pedro Sousa
← Articles

Article · Blog

SwiftUI Preview as an Architecture Sensor

When a Preview starts demanding acrobatics just to work, the problem is rarely the Preview. The problem is the architecture. I learned that the hard way, trying to fix the symptom instead of the cause.

SwiftUI Preview as an Architecture Sensor

There's a pattern that took me a while to name. I'd call it Fragile Preview Syndrome.

You open the canvas in Xcode, the Preview crashes, you wire up a bunch of manual mocks, it loads, you move on. That becomes routine. Weeks later, the View has twenty hand-injected dependencies, the PreviewProvider is a hundred lines long, and nobody uses the canvas anymore because "it's too much work."

The team stops using Preview. Blames Xcode. Blames SwiftUI. Never blames the architecture.

I was that team for a while.

The real problem isn't the Preview

SwiftUI's Preview has a simple contract: it instantiates your View in isolation, outside the app's lifecycle, with no access to navigation context, no automatic dependency injection, no configured environment. If your View can't exist in that isolation, it's too tightly coupled.

That sounds obvious when written out like this. In practice, it isn't.

In a financial app with several investment flow modules, we had Views that needed an EnvironmentObject that only existed if the AppCoordinator was alive. When we tried to set up the Preview, Xcode would simply crash or show a blank screen because the @EnvironmentObject never arrived. The solution we went with at the time: create a monstrous AppEnvironment.mock() that initialized half the app. Preview started working. Problem "solved."

That wasn't a solution. It was symptom suppression.

What Preview difficulty is actually telling you

When a View is hard to preview, it's signaling at least one of these things:

It knows too much about the environment. If the View accesses @EnvironmentObject of a concrete type, it depends on whoever sets up that environment. Switching to a protocol or using @Environment with a custom key isolates the contract.

It does too many things. A View that fires a network call, accesses UserDefaults, reads from the Keychain, or instantiates a service inside body has no business in a canvas. That belongs in the ViewModel or a use case layer.

The ViewModel doesn't accept external state. A ViewModel initialized with a no-argument init() that loads everything internally is impossible to preview in any state other than the initial one. If you can't construct a ViewModel already in an "error" or "loading" state to drop into a Preview, the state design is wrong.

How things looked different in practice

I started using Preview as a design test. Before considering a View done, it needs at least three working Previews with no giant mock:

  1. Initial or empty state
  2. Loaded state with typical real data
  3. Error state

If any of the three needs more than ten lines of setup, there's coupling that shouldn't be there.

The pattern that emerged was separating observable state from loading logic. Instead of a ViewModel that loads and exposes state at the same time, the ViewModel only exposes @Published var state: ViewState, where ViewState is an enum with the relevant cases. The ViewModel receives its dependencies via init. The View receives the ViewModel via init. Preview instantiates directly:

#Preview("Balance Error") {
    BalanceView(viewModel: BalanceViewModel(
        state: .error("Service unavailable"),
        service: MockBalanceService()
    ))
}

That works. No acrobatics. No two-hundred-line setup.

The observation that goes against conventional wisdom

There's a belief that Preview is a feature for designers or for people who use Xcode in "pretty mode." A real engineer writes code, doesn't play around with the canvas.

I completely disagree.

Preview is the only place where you force your View to exist without the app's scaffolding. It's an isolation test that the compiler doesn't run for you. If you never use Preview, you're giving up immediate feedback on coupling. You'll discover the problem later, when you try to write a UI test or when you need to reuse the component in a different context.

The difficulty of writing a Preview and the difficulty of writing a unit test for a View have the same root cause. They're not two problems. They're the same problem with two different symptoms.

The cost that shows up later

In a project with around twenty modularized onboarding screens, the team had abandoned Preview entirely. Every component was coupled to the navigation flow through a coordinator. Reusing any component in another module meant dragging the entire coordinator along with it. Triggering a specific visual state to test an edge case meant manually navigating to that screen in the simulator.

UI iteration time grew gradually. Nobody connected that to the coupling. It felt like the natural cost of a mature app.

When we eventually refactored the Views to accept state through injection and isolated the navigation dependencies, iteration cost dropped significantly. Not because Preview is magic. Because the isolation Preview demands is the same thing that makes change, reuse, and testing easier.

Honest trade-offs

This model requires discipline. It's easier to drop in an @EnvironmentObject and move on than to design a mockable service protocol. It's faster to let the ViewModel load everything internally than to expose state as an injectable enum.

And there's a real cost: more types. More protocols. More files. On small teams or projects with tight deadlines, that can be real overhead, not theoretical.

The decision to apply this pattern isn't universal. I apply it more strictly to components that will be reused or that have complex states. On simple one-off screens, I accept some coupling.

The mistake is never questioning the coupling because Preview "never really worked properly anyway."

What I would do differently today

I would establish an explicit convention from the start: no View goes to code review without at least two working #Preview blocks. Not as bureaucracy. As an architecture signal.

When the Preview doesn't work, the pull request comes back. Not because Preview is sacred, but because it's saying that something in the design deserves attention before it reaches production.


A fragile Preview is documentation of technical debt that Xcode renders for you in real time.

Most people close the canvas and move on. That makes sense in the short term... until the day you realize the canvas stopped working six months ago and nobody noticed, because the code could no longer exist outside the app that created it.