.agents/skills/swiftui-expert-skill/references/performance-patterns.md
SwiftUI doesn't compare values before triggering updates:
// BAD - triggers update even if value unchanged
.onReceive(publisher) { value in
self.currentValue = value // Always triggers body re-evaluation
}
// GOOD - only update when different
.onReceive(publisher) { value in
if self.currentValue != value {
self.currentValue = value
}
}
Hot paths are frequently executed code (scroll handlers, animations, gestures):
// BAD - updates state on every scroll position change
.onPreferenceChange(ScrollOffsetKey.self) { offset in
shouldShowTitle = offset.y <= -32 // Fires constantly during scroll!
}
// GOOD - only update when threshold crossed
.onPreferenceChange(ScrollOffsetKey.self) { offset in
let shouldShow = offset.y <= -32
if shouldShow != shouldShowTitle {
shouldShowTitle = shouldShow // Fires only when crossing threshold
}
}
Avoid passing large "config" or "context" objects. Pass only the specific values each view needs.
// Good - pass specific values
ThemeSelector(theme: config.theme)
FontSizeSlider(fontSize: config.fontSize)
// Avoid - passing entire config (creates broad dependency)
ThemeSelector(config: config) // Notified of ALL config changes
With ObservableObject, any @Published change triggers all observers. With @Observable, views update only when accessed properties change, but passing entire objects still creates broader dependencies than necessary.
For views with expensive bodies, conform to Equatable:
struct ExpensiveView: View, Equatable {
let data: SomeData
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.data.id == rhs.data.id // Custom equality check
}
var body: some View {
// Expensive computation
}
}
// Usage
ExpensiveView(data: data)
.equatable() // Use custom equality
Caution: If you add new state or dependencies to your view, remember to update your == function!
POD (Plain Old Data) views use memcmp for fastest diffing. A view is POD if it only contains simple value types and no property wrappers.
// POD view - fastest diffing
struct FastView: View {
let title: String
let count: Int
var body: some View {
Text("\(title): \(count)")
}
}
// Non-POD view - uses reflection or custom equality
struct SlowerView: View {
let title: String
@State private var isExpanded = false // Property wrapper makes it non-POD
var body: some View {
Text(title)
}
}
Advanced Pattern: Wrap expensive non-POD views in POD parent views:
// POD wrapper for fast diffing
struct ExpensiveView: View {
let value: Int
var body: some View {
ExpensiveViewInternal(value: value)
}
}
// Internal view with state
private struct ExpensiveViewInternal: View {
let value: Int
@State private var item: Item?
var body: some View {
// Expensive rendering
}
}
Why: The POD parent uses fast memcmp comparison. Only when value changes does the internal view get diffed.
Use lazy containers for large collections:
// BAD - creates all views immediately
ScrollView {
VStack {
ForEach(items) { item in
ExpensiveRow(item: item)
}
}
}
// GOOD - creates views on demand
ScrollView {
LazyVStack {
ForEach(items) { item in
ExpensiveRow(item: item)
}
}
}
iOS 26+ note: Nested scroll views containing lazy stacks now automatically defer loading their children until they are about to appear, matching the behavior of top-level lazy stacks. This benefits patterns like horizontal photo carousels inside a vertical scroll view.
Source: "What's new in SwiftUI" (WWDC25, session 256)
Cancel async work when view disappears:
struct DataView: View {
@State private var data: [Item] = []
var body: some View {
List(data) { item in
Text(item.name)
}
.task {
// Automatically cancelled when view disappears
data = await fetchData()
}
}
}
Use Self._printChanges() or Self._logChanges() to debug unexpected view updates.
struct DebugView: View {
@State private var count = 0
@State private var name = ""
var body: some View {
#if DEBUG
let _ = Self._logChanges() // Xcode 15.1+: logs to com.apple.SwiftUI subsystem
#endif
VStack {
Text("Count: \(count)")
Text("Name: \(name)")
}
}
}
Self._printChanges(): Prints which properties changed to standard output.Self._logChanges() (iOS 17+): Logs to the com.apple.SwiftUI subsystem with category "Changed Body Properties", using os_log for structured output.Both print @self when the view value itself changed and @identity when the view's persistent data was recycled.
Why: This helps identify which state changes are causing view updates. Isolating redraw triggers into single-responsibility subviews is often the fix -- extracting a subview means SwiftUI can skip its body when its inputs haven't changed.
Narrow state scope to reduce update fan-out. Instead of passing an entire @Observable model to a row view (which creates a dependency on all accessed properties), pass only the specific values the view needs as let properties.
// Bad - broad dependency on entire model
struct ItemRow: View {
@Environment(AppModel.self) private var model
let item: Item
var body: some View { Text(item.name).foregroundStyle(model.theme.primaryColor) }
}
// Good - narrow dependency
struct ItemRow: View {
let item: Item
let themeColor: Color
var body: some View { Text(item.name).foregroundStyle(themeColor) }
}
Avoid storing frequently-changing values in the environment. Even when a view doesn't read the changed key, SwiftUI still checks all environment readers. This cost adds up with many views and frequent updates (geometry values, timers).
Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306)
Consider per-item @Observable state holders (one per row/item) to narrow update scope. When multiple list items share a dependency on the same @Observable array, changing one element causes all items to re-evaluate their bodies.
// BAD - all item views depend on the full favorites array
@Observable
class ModelData {
var favorites: [Landmark] = []
func isFavorite(_ landmark: Landmark) -> Bool {
favorites.contains(landmark)
}
}
struct LandmarkRow: View {
let landmark: Landmark
@Environment(ModelData.self) private var model
var body: some View {
HStack {
Text(landmark.name)
if model.isFavorite(landmark) {
Image(systemName: "heart.fill")
}
}
}
}
// GOOD - each item has its own observable view model
@Observable
class LandmarkViewModel {
var isFavorite: Bool = false
}
struct LandmarkRow: View {
let landmark: Landmark
let viewModel: LandmarkViewModel
var body: some View {
HStack {
Text(landmark.name)
if viewModel.isFavorite {
Image(systemName: "heart.fill")
}
}
}
}
Why: With the bad pattern, toggling one favorite marks the entire array as changed, causing every LandmarkRow to re-run its body. With per-item view models, only the toggled item's body runs.
Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306)
SwiftUI may call certain closures on a background thread for performance. These closures must be Sendable and should avoid accessing @MainActor-isolated state directly. Instead, capture needed values in the closure's capture list.
Closures that may run off the main thread:
Shape.path(in:)visualEffect closureLayout protocol methodsonGeometryChange transform closure// BAD - accessing @MainActor state directly
.visualEffect { content, geometry in
content.blur(radius: self.pulse ? 5 : 0) // Compiler error: @MainActor isolated
}
// GOOD - capture the value
.visualEffect { [pulse] content, geometry in
content.blur(radius: pulse ? 5 : 0)
}
Source: "Explore concurrency in SwiftUI" (WWDC25, session 266)
Be aware of common performance bottlenecks in SwiftUI:
body (formatting, sorting, image decoding)When performance issues arise, suggest the user profile with Instruments (SwiftUI template) to identify specific bottlenecks.
// BAD - creates new formatter every body call
var body: some View {
let formatter = DateFormatter()
formatter.dateStyle = .long
return Text(formatter.string(from: date))
}
// GOOD - static or stored formatter
private static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .long
return f
}()
var body: some View {
Text(Self.dateFormatter.string(from: date))
}
Keep view body simple and pure. Avoid side effects, dispatching, or complex logic.
// BAD - sorts array every body call
var body: some View {
List(items.sorted { $0.name < $1.name }) { item in Text(item.name) }
}
// GOOD - compute once, update via onChange or a computed property in the model
@State private var sortedItems: [Item] = []
var body: some View {
List(sortedItems) { item in Text(item.name) }
.onChange(of: items) { _, newItems in
sortedItems = newItems.sorted { $0.name < $1.name }
}
}
Move sorting, filtering, and formatting into models or computed properties. The body should be a pure structural representation of state.
// BAD - derived state stored separately
@State private var items: [Item] = []
@State private var itemCount: Int = 0 // Unnecessary!
// GOOD - compute derived values
@State private var items: [Item] = []
var itemCount: Int { items.count } // Computed property
LazyVStack/LazyHStackbodybodySelf._logChanges() or Self._printChanges() to debug unexpected updates