SwiftUI Isn't Slow — Your Code Is
Source: Dev.to
SwiftUI Rendering: What’s Fast and What’s Slow
SwiftUI’s rendering engine is fast. What slows it down is the work you ask it to redo hundreds of times per second.
How SwiftUI Works
SwiftUI doesn’t have a viewDidLoad that fires once. Instead it uses a declarative diffing system:
- State changes – you update an
@State,@Binding,@ObservedObject, or any other source of truth. bodyis called – SwiftUI re‑evaluatesbodyfor every view that depends on that state.- Diffing happens – SwiftUI compares the new view tree with the previous one.
- Only differences are rendered – if nothing changed, nothing is redrawn.
This is elegant and efficient when body is cheap to evaluate.
The problem? SwiftUI can’t know whether your body computation is lightweight. If you put expensive work inside body, it will be re‑run on every single state change.
Example: A Personal‑Finance Dashboard
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 $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) }
}
}
}
}
The user taps “Details” → showDetails changes → body re‑evaluates → every aggregation re‑runs, even though the transaction data hasn’t changed.
Fix: Pre‑compute When the Period Changes
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
)
}
}
}
Now body only reads a struct and maps it to views—no filtering, grouping, or sorting. Toggling showDetails is instant, and the heavy work runs only when selectedPeriod changes (off the main thread).
Common Performance Tips
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 = {
let f = DateFormatter()
// configure f …
return f
}()
Text(Self.formatter.string(from: date))
The same applies to NumberFormatter, JSONDecoder, regular‑expression patterns, etc.—anything expensive but stateless.
2. Avoid .sorted() / .filter() Directly in body
// ❌ Sorts on every evaluation
List(items.sorted(by: { $0.date > $1.date })) { ... }
// ✅ Sort when data changes
@State private var sortedItems: [Item] = []
.onChange(of: items) { _, new in
sortedItems = new.sorted(by: { $0.date > $1.date })
}
If you see these inside body, it’s a code smell. Move them to .onChange, .task, or a dedicated view model and store the result in @State.
3. Keep Views Small
// ❌ AvatarImage rebuilds when unrelated state 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())
}
}
Smaller, isolated views mean fewer unnecessary recomputations.
4. Prefer @Observable Over @ObservableObject (iOS 17+)
// ❌ With ObservableObject, any @Published change forces a full view refresh
class UserModel: ObservableObject {
@Published var name: String
@Published var bio: String
}
// ✅ @Observable tracks per‑property, so only the views that read a changed
// property are refreshed
@Observable class UserModel {
var name: String
var bio: String
}
Granular tracking reduces the amount of UI that needs to be redrawn.
5. Use .task(id:) for Asynchronous Work
// Runs off the main thread, cancels automatically when `id` changes
.task(id: selectedPeriod) {
summary = await computeSummary(from: transactions,
for: selectedPeriod)
}
This keeps heavy work out of body and ensures it’s cancelled if the relevant input changes.
TL;DR
bodyshould be cheap – avoid heavy calculations, sorting, filtering, or object creation inside it.- Cache or pre‑compute expensive results when their inputs change (using
@State,.task,.onChange, etc.). - Break large views into smaller sub‑views so only the parts that truly need to update are recomputed.
- Leverage
@Observablefor per‑property change tracking (iOS 17+). - Run async work with
.task(id:)to keep the main thread responsive.
By following these patterns, you let SwiftUI’s diffing engine do what it does best—render only what actually changed.
SwiftUI Performance Tips
“Treat
bodylike it’s called 100 times per second. Because sometimes it is.”
Common Pitfall
// Fires on every body evaluation
let _ = Task { await loadData() }
// ✅ Only runs when `selectedCategory` changes
.task(id: selectedCategory) { await loadData(for: selectedCategory) }
How to Diagnose
- Open Instruments → SwiftUI template → View Body invocations.
- If a view’s
bodyis called 200 times when you expect 2, you’ve found the problem.
Never optimize based on intuition—let the profiler show you what’s happening.
Best Practices
- Keep
bodypure and lightweight: it should only read pre‑computed state and map it to views. - Avoid expensive work inside
body. Move any heavy lifting totask,onAppear, or view models. - Use
.task(id:)(or similar modifiers) to run async work only when its dependencies change.
Bottom Line
SwiftUI isn’t slow; it penalizes expensive work done in the wrong place.
Once you understand the rendering cycle, performance problems disappear, letting you stop blaming the framework and start shipping smooth apps.
Found this helpful? Follow me for more SwiftUI deep‑dives and drop your performance war stories in the comments!