Making SwiftUI previews actually usable
For about a year I did not use SwiftUI previews. They timed out, they crashed on views that needed environment objects, and re-running the app was faster than waiting for one to build.
Getting them working was three structural changes, and the design feedback loop it produced was worth considerably more than the time it took.
Why they were broken
Build times. A preview compiles the module containing the view plus everything it depends on. In a monolithic app that is the whole app, every time. This is the biggest cause and modularisation is the biggest fix.
Missing dependencies. A view reading @Environment(Library.self) crashes in a preview unless the
preview injects it. So does anything calling a singleton that expects a configured app.
Real work at construction. A view whose initialiser opens a database or starts a network request does that in the preview too, and previews have no network and no configured stack.
Fix one: make the view take its data
The single highest-leverage change. A view that takes what it needs as parameters previews trivially:
// hard to preview: fetches its own data
struct ProfileView: View {
@State private var profile: Profile?
let userID: User.ID
var body: some View {
…
.task { profile = await api.profile(for: userID) }
}
}
// trivial to preview: given its data
struct ProfileContent: View {
let profile: Profile
var body: some View { … }
}
Split the fetching container from the presenting view. The container is thin and not worth previewing; the presenting view is where all the design lives, and it previews with a literal:
#Preview {
ProfileContent(profile: .sample)
}
That separation is good design independently of previews — it is the same split that makes the view testable and reusable — which is the argument I would make even to someone who never opens a preview.
Fix two: sample data as static properties
extension Profile {
static let sample = Profile(
id: UUID(),
name: "Nguyễn Tuấn Anh",
bio: "iOS developer in Hanoi",
followers: 1_284
)
static let longName = Profile(
id: UUID(),
name: "A name that is deliberately far too long to fit on one line",
bio: "",
followers: 0
)
}
The second one matters more than the first. Sample data should include the cases that break the layout — the empty string, the enormous number, the missing image, the name in a script with different metrics. Those are the cases you otherwise find in a screenshot from a user.
Tip
Put sample data behind #if DEBUG so it does not ship, and keep it beside the model rather than
in the preview. Once it exists, tests use it too, and the sample that reproduced a layout bug
becomes the fixture for the test that prevents it.
Fix three: preview the states, not the screen
A single preview of the happy path is the least useful one. Previewing every state is where the value is:
#Preview("Loaded") {
ProfileContent(profile: .sample)
}
#Preview("Long name") {
ProfileContent(profile: .longName)
}
#Preview("Dark") {
ProfileContent(profile: .sample)
.preferredColorScheme(.dark)
}
#Preview("Accessibility XXL") {
ProfileContent(profile: .sample)
.environment(\.dynamicTypeSize, .accessibility5)
}
That last one has caught more layout bugs than every other preview combined. Text at the largest accessibility size breaks layouts that look fine at the default, and checking it takes one preview instead of changing a system setting and relaunching.
The things that make them fast
Modularise. A preview in a leaf module compiles that module, not the app. This is the difference between a two-second preview and a forty-second one.
Keep #Preview blocks small. Previewing a whole navigation stack compiles the whole navigation
stack. Preview the leaf.
Avoid singletons in previewed views. Anything reaching for .shared drags the entire dependency
graph into the preview build.
@Previewable for state. For a view needing a binding, this avoids a wrapper view:
#Preview {
@Previewable @State var isOn = true
ToggleRow(title: "Notifications", isOn: $isOn)
}
What it changed
I expected faster iteration. What I got was different work.
Building a screen in a preview with six states side by side means designing the empty state, the error state and the loading state at the same time as the happy path — rather than adding them later when someone reports that the screen is blank on a bad connection.
That reordering is the actual value. The empty state stopped being an afterthought because it was on screen next to the populated one the whole time I was working.