DEV Community

Cover image for SwiftUI Isn't Slow — Your Code Is
ArshTechPro
ArshTechPro

Posted on

SwiftUI Isn't Slow — Your Code Is

SwiftUI's rendering engine is fast. What's slow is the work you're asking it to redo hundreds of times per second.

How SwiftUI Actually Renders Your Views

SwiftUI doesn't work like UIKit. There's no viewDidLoad that fires once. Instead, SwiftUI uses a declarative diffing system:

  1. State changes. You update an @State, @Binding, @ObservedObject, or any other source of truth.
  2. body is called. SwiftUI re-evaluates body for every view that depends on that state.
  3. Diffing happens. SwiftUI compares the new view tree with the previous one.
  4. Only differences are rendered. If nothing changed, nothing is redrawn.

This is elegant and efficient — when body is cheap to evaluate.

The problem? SwiftUI has no way to know whether your body computation is lightweight or not. It trusts you. If you put expensive work inside body, it will faithfully re-run that work on every. single. state change.


The #1 Performance Killer: Expensive Work in body

Imagine a personal finance dashboard — total spent, daily average, category breakdown, top merchants. The user can switch between "This Month" and "Last Month," and expand a details section.

The Slow Version

struct DashboardView: View {
    @State private var selectedPeriod: Period = .thisMonth
    @State private var showDetails = false
    let transactions: [Transaction]

    var body: some View {
        //  ALL of this recalculates on EVERY body call
        // — even when the user just toggles showDetails!
        let filtered = transactions.filter {
            $0.date >= selectedPeriod.startDate &&
            $0.date <= selectedPeriod.endDate
        }
        let totalSpent = filtered.reduce(0) { $0 + $1.amount }
        let dailyAverage = totalSpent / Double(selectedPeriod.numberOfDays)
        let byCategory = Dictionary(grouping: filtered, by: \.category)
            .mapValues { $0.reduce(0) { $0 + $1.amount } }
            .sorted { $0.value > $1.value }

        ScrollView {
            VStack(spacing: 16) {
                PeriodPicker(selection: $selectedPeriod)
                SummaryCard(title: "\"Total\", value: totalSpent)"
                SummaryCard(title: "\"Daily Avg\", value: dailyAverage)"
                ForEach(byCategory, id: \.key) { cat, amt in
                    CategoryRow(name: cat, amount: amt)
                }
                Button("Details") { withAnimation { showDetails.toggle() } }
                if showDetails { MerchantList(transactions: filtered) }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The user taps "Details"showDetails changes → body re-evaluates → every aggregation re-runs, even though the transaction data hasn't changed. The user just wanted to expand a section and paid for a full recalculation.


The Fast Version

Precompute once when the period changes. Let body just read the results.

struct DashboardView: View {
    @State private var selectedPeriod: Period = .thisMonth
    @State private var showDetails = false
    @State private var summary: DashboardSummary?
    let transactions: [Transaction]

    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                PeriodPicker(selection: $selectedPeriod)
                if let summary {
                    SummaryCard(title: "Total", value: summary.totalSpent)
                    SummaryCard(title: "Daily Avg", value: summary.dailyAverage)
                    ForEach(summary.categoryBreakdown, id: \.category) { item in
                        CategoryRow(name: item.category, amount: item.amount)
                    }
                    Button("Details") { withAnimation { showDetails.toggle() } }
                    if showDetails {
                        MerchantList(merchants: summary.topMerchants)
                    }
                } else {
                    ProgressView()
                }
            }
        }
        .task(id: selectedPeriod) {
            summary = await computeSummary(
                from: transactions, for: selectedPeriod
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now body just reads a struct and maps it to views — no filtering, no grouping, no sorting. Toggling showDetails is instant. The heavy work only re-runs when selectedPeriod changes, off the main thread.


Quick Tips to Keep body Fast

1. Cache expensive objects

//  New DateFormatter on every body call
Text(DateFormatter.localizedString(from: date, dateStyle: .long, timeStyle: .short))

//  Static — created once
private static let formatter: DateFormatter = { /* configure */ }()
Text(Self.formatter.string(from: date))
Enter fullscreen mode Exit fullscreen mode

Same goes for NumberFormatter, JSONDecoder, regex patterns — anything expensive but stateless.

2. No .sorted() or .filter() in body

If you see these inside body, it's a code smell. Move them to .onChange or .task and store the result in @State.

//  Sorts on every evaluation
List(items.sorted(by: { $0.date > $1.date })) { ... }

//  Sort when data changes
.onChange(of: items) { _, new in sortedItems = new.sorted(by: { $0.date > $1.date }) }
Enter fullscreen mode Exit fullscreen mode

3. Break large views into small ones

SwiftUI only re-evaluates body for views whose observed state changed. Smaller views = fewer unnecessary recomputations.

//  AvatarImage rebuilds when isEditing changes
struct ProfileView: View {
    @State private var isEditing = false
    var body: some View {
        VStack {
            AvatarImage(url: user.avatarURL) // unnecessary rebuild!
            Toggle("Edit", isOn: $isEditing)
        }
    }
}

//  Extract it — now it only rebuilds when avatarURL changes
struct AvatarImage: View {
    let url: URL
    var body: some View { AsyncImage(url: url).frame(width: 80, height: 80).clipShape(Circle()) }
}
Enter fullscreen mode Exit fullscreen mode

4. Prefer @Observable over @ObservableObject

With ObservableObject, any @Published change re-evaluates every observing view. @Observable (iOS 17+) tracks per-property, so views only update when the properties they actually read change.

//  Changing bio re-renders views that only read name
class UserModel: ObservableObject {
    @Published var name: String
    @Published var bio: String
}

//  Granular tracking
@Observable class UserModel {
    var name: String
    var bio: String
}
Enter fullscreen mode Exit fullscreen mode

5. Use .task(id:) for async work

It runs off body, cancels automatically on id change, and keeps your view clean.

//  Fires on every body evaluation
let _ = Task { await loadData() }

// ✅ Only runs when selectedCategory changes
.task(id: selectedCategory) { await loadData(for: selectedCategory) }
Enter fullscreen mode Exit fullscreen mode

6. Profile before you guess

Open Instruments → SwiftUI template → View Body invocations. If a view's body is called 200 times when you expect 2, you've found your problem. Don't optimize based on intuition — let the profiler show you.


The Mental Model

Treat body like it's called 100 times per second. Because sometimes it is.

Your body should be a pure, lightweight function that reads precomputed state and maps it to views. Nothing more.

SwiftUI isn't slow. It just punishes you for doing expensive things in the wrong place. Once you understand the rendering cycle, the performance problems disappear — and you can stop blaming the framework and start shipping smooth apps.


Found this helpful? Follow me for more SwiftUI deep-dives. Drop your performance war stories in the comments.

Top comments (2)

Collapse
 
arshtechpro profile image
ArshTechPro

Your body should be a pure, lightweight function that reads precomputed state and maps it to views. Nothing more.

Collapse
 
winc_leo_fa4daeeff03ad509 profile image
Winc Leo

This is good information to know swift template