Why SwiftUI Previews Run in Their Own Little World (and How to Work With It)
SwiftUI previews fail in ways the simulator never does because they run a minimal, constrained build of your app. Here's why Apple made that trade-off and how to design views that preview well.
28 Dec 2025, 07:58 UTC

If you've ever watched a SwiftUI preview spin forever and then show a red error banner for a view that works perfectly fine in the simulator, you've hit the central engineering trade-off of Xcode's preview system: it is not your app. It's a deliberately constrained, partial build of your app, optimized for iteration speed rather than fidelity. Understanding that decision makes the failures predictable instead of mysterious.
This post assumes a recent Xcode (15 or later, where the #Preview macro is the default) and an iOS-targeted SwiftUI project.
The problem previews were built to solve
Before previews, the feedback loop for UI work was brutal: change a corner radius, build the whole app, launch a simulator, navigate to the screen, check the result. That loop could easily take a minute, and UI work is nothing but tiny adjustments. Previews exist to compress that loop to a few seconds by compiling only what a single view needs and rendering it inside the Xcode canvas.
A minimal build, not a mini simulator
The key decision Apple made is that previews use a specialized build process that compiles a minimal slice of your target — the view and whatever it transitively depends on — and hosts it in a separate, constrained process. It does not run your app delegate, your scene setup, or your dependency injection bootstrap. That's why iteration is fast, and it's also why previews break in ways the simulator never does.
The PreviewProvider protocol (older style) and the #Preview macro (Xcode 15+) exist precisely to draw a boundary around that slice. Everything the view needs must be supplied inside the preview block, because nothing from your app's real startup sequence will run.
A worked example: previewing a view with dependencies
Say you have a profile view that reads from a shared UserStore singleton. In the app this works because the store is configured at launch. In a preview it fails or shows empty data, because launch never happened. The fix is to construct the dependency inline:
struct ProfileView: View {
@ObservedObject var store: UserStore
var body: some View {
VStack {
Text(store.userName).font(.headline)
Text(store.isOnline ? "Online" : "Offline")
.foregroundStyle(store.isOnline ? .green : .secondary)
}
}
}
#Preview("Online") {
ProfileView(store: UserStore.preview(userName: "Ada", isOnline: true))
}
#Preview("Offline") {
ProfileView(store: UserStore.preview(userName: "Ada", isOnline: false))
}The pattern to notice: UserStore.preview(...) is a lightweight factory that returns a store seeded with fake data, kept in debug-only code. Passing dependencies in through the initializer (rather than reaching for a singleton inside the view) is what makes the view previewable at all. This is the real architectural pressure previews apply — they reward explicit dependency injection and punish hidden global state.
Live vs. static mode
The canvas offers two modes. Static mode renders a snapshot quickly — good for layout checks across many variants. Live mode actually runs the view, so @State, animations, and button taps work. The trade-off is that live mode is slower to spin up and more likely to expose environment differences. A practical workflow: stay in static mode while nudging layout, switch to live when you need to verify interaction, and confirm anything timing- or environment-sensitive on a real simulator before calling it done.
Where the trade-off bites
Three failure modes follow directly from the minimal-build design:
- Missing dependencies. Views that pull from singletons, databases, or network layers configured at app launch will fail to render. Inject fakes in the preview block.
- Heavy initializers. Work done in a view's initializer runs on every preview refresh. Expensive computation there turns your two-second feedback loop into a thirty-second one, or crashes the canvas outright. Move it into a view model or load it lazily.
- Environment drift. Environment variables, scheme settings, and some system behaviors differ between the preview host and the simulator. If a bug only reproduces on-device, trust the device.
How to check your previews are honest
A preview that renders is not proof the view is correct. A quick verification pass: add a #Preview with a @State-driven control, run it in live mode, and confirm state survives interaction (e.g., a toggle updates the label). Then run the same screen in the simulator and compare behavior — especially anything involving networking, persistence, or permissions. When the two disagree, the simulator wins.
The takeaway
Previews trade fidelity for speed on purpose. Once you accept that the canvas is a partial build in a constrained host, the fix for most preview failures is the same: make dependencies explicit, keep initializers cheap, and treat the simulator as the final authority. If a view is painful to preview, that pain is usually a design smell worth fixing in the view itself.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.