A Practical Guide to SwiftUI Previews

SwiftUI previews are essential for iterating on views quickly, but most projects use them far below their potential. A preview that shows an empty view with default data is not useful. A preview that shows real states — loading, error, populated, dark mode, different locales — can replace most of your visual testing.

This guide covers preview techniques for production SwiftUI projects: previewing multiple states, working with dependencies, mock data, device configurations, and avoiding common pitfalls.


Previewing state variations

The most impactful improvement: show every state your view can be in.

Multiple previews

#Preview("Populated") {
    ContentView(items: sampleItems)
}

#Preview("Empty") {
    ContentView(items: [])
}

#Preview("Loading") {
    ContentView(isLoading: true, items: [])
}

#Preview("Error") {
    ContentView(error: "Failed to load data", items: [])
}

Xcode renders each preview in a separate canvas pane. You see all states at once.

Preview macros

With the #Preview macro (iOS 17+ / Xcode 15+), each preview is independent:

#Preview {
    ProfileView(user: .previewAlice)
}

#Preview {
    ProfileView(user: .previewBob)
        .preferredColorScheme(.dark)
}

#Preview {
    ProfileView(user: .previewCharlie)
        .environment(\.locale, .init(identifier: "vi"))
}

Mock data

Static preview instances

Add preview-specific static properties to your models:

extension User {
    static let previewAlice = User(
        id: UUID(),
        name: "Alice Nguyen",
        avatar: "alice",
        isPro: true
    )

    static let previewBob = User(
        id: UUID(),
        name: "Bob Smith",
        avatar: nil,
        isPro: false
    )
}

Preview helpers file

Create a PreviewContent.swift file for mock data used across previews:

enum PreviewData {
    static let articles: [Article] = [
        Article(id: "1", title: "Getting Started with SwiftUI", date: .now),
        Article(id: "2", title: "Advanced Navigation Patterns", date: .now.addingTimeInterval(-86400)),
        Article(id: "3", title: "Performance Optimization Tips", date: .now.addingTimeInterval(-172800)),
    ]

    static let comments: [Comment] = [
        Comment(id: "1", author: "Alice", text: "Great article!"),
        Comment(id: "2", author: "Bob", text: "This helped a lot, thanks."),
    ]
}

Dependency injection in previews

If your views use services or environment objects, provide preview-specific stubs.

Using protocols

protocol APIClientProtocol {
    func fetchArticles() async throws -> [Article]
}

// Preview stub
struct PreviewAPIClient: APIClientProtocol {
    let result: [Article]

    func fetchArticles() async throws -> [Article] {
        result
    }
}

#Preview("With data") {
    let client = PreviewAPIClient(result: PreviewData.articles)
    let viewModel = ArticleListViewModel(client: client)

    ArticleListView(viewModel: viewModel)
}

Environment-based injection

private struct APIClientKey: EnvironmentKey {
    static let defaultValue: APIClientProtocol = ProductionAPIClient()
}

extension EnvironmentValues {
    var apiClient: APIClientProtocol {
        get { self[APIClientKey.self] }
        set { self[APIClientKey.self] = newValue }
    }
}

#Preview {
    ArticleListView()
        .environment(\.apiClient, PreviewAPIClient(result: []))
}

Device and orientation configurations

Preview multiple devices without switching schemes:

#Preview("iPhone", traits: .portrait) {
    ContentView()
}

#Preview("iPad", traits: .landscapeRight) {
    ContentView()
}

#Preview("iPhone SE", traits: .fixedLayout(width: 375, height: 667)) {
    ContentView()
}

Fixed layout modifier

For precise size control:

#Preview {
    ContentView()
        .previewDevice("iPhone SE (3rd generation)")
}

Dynamic Type sizes

#Preview {
    ContentView()
        .dynamicTypeSize(.accessibility5)
}

#Preview("Large Text") {
    ContentView()
        .environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
}

Dark mode, localization, and accessibility

Dark mode

#Preview("Dark") {
    ContentView()
        .preferredColorScheme(.dark)
}

Localization

#Preview("Vietnamese") {
    ContentView()
        .environment(\.locale, Locale(identifier: "vi"))
        .environment(\.layoutDirection, .leftToRight)
}

#Preview("Arabic") {
    ContentView()
        .environment(\.locale, Locale(identifier: "ar"))
        .environment(\.layoutDirection, .rightToLeft)
}

Accessibility

#Preview("Bold Text") {
    ContentView()
        .environment(\.legibilityWeight, .bold)
}

#Preview("Reduced Motion") {
    ContentView()
        .environment(\.accessibilityReduceMotion, true)
}

Grouping previews

For related previews, group them:

#Preview(traits: .sizeThatFitsLayout) {
    VStack {
        ProfileView(user: .previewAlice)
        ProfileView(user: .previewBob)
        ProfileView(user: .previewCharlie)
    }
}

Previewing wrapped UIKit views

When working with a UIViewRepresentable:

#Preview {
    MapView()
        .frame(height: 300)
}

Common pitfalls

Preview crashes on launch

If your app uses AppDelegate or @main logic that crashes without certain conditions, SwiftUI previews may fail. Guard early using ProcessInfo:

if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" {
    // Skip initialization that crashes in previews
}

Preview not updating

  • Previews do not re-render for @AppStorage or UserDefaults changes during a preview session.
  • Previews may not reflect changes to localized strings until the preview is rebuilt.

Slow previews

  • Avoid real network calls in previews. Use mock data.
  • Minimize @State mutations in preview bodies.
  • Use .sizeThatFitsLayout trait for views that do not need full-screen rendering.

State loss between preview refreshes

Previews re-create the view tree on each code change. Any @State is discarded and re-initialized. If you need to persist state across preview edits, lift it to a parent or use @State with initial values that match your preview scenario.

Use @Previewable (iOS 17+, Xcode 16+) to declare mutable @State inline in a #Preview without creating a wrapper view:

#Preview {
    @Previewable @State var toggled = true
    Toggle("Loud Noises", isOn: $toggled)
}

Previewing SwiftUI + UIKit interop

#Preview works directly with UIViewController and UIView instances:

#Preview {
    let vc = UIHostingController(rootView: ProfileView(user: .previewAlice))
    vc.view.frame = CGRect(x: 0, y: 0, width: 400, height: 800)
    return vc
}

For custom UIViewController subclasses, just pass them in directly:

#Preview {
    CustomViewController()
}

A preview checklist

Before considering a preview “done,” verify:

  • At least one populated‑data preview exists.
  • Empty/loading/error states each have a preview (where applicable).
  • Dark mode is tested.
  • At least one device size is tested (iPhone SE + Pro Max is a good pair).
  • Key accessibility settings (large text, reduced motion) are tested.
  • Mock data is separated from production code.