Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Reviews, improves, and writes SwiftUI code following state management, view composition, performance, and iOS 26+ Liquid Glass best practices.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
references/performance-patterns.md
1# SwiftUI Performance Patterns Reference23## Table of Contents45- [Performance Optimization](#performance-optimization)6- [Anti-Patterns](#anti-patterns)7- [Summary Checklist](#summary-checklist)89## Performance Optimization1011### 1. Avoid Redundant State Updates1213SwiftUI doesn't compare values before triggering updates:1415```swift16// BAD - triggers update even if value unchanged17.onReceive(publisher) { value in18self.currentValue = value // Always triggers body re-evaluation19}2021// GOOD - only update when different22.onReceive(publisher) { value in23if self.currentValue != value {24self.currentValue = value25}26}27```2829### 2. Optimize Hot Paths3031Hot paths are frequently executed code (scroll handlers, animations, gestures):3233```swift34// BAD - updates state on every scroll position change35.onPreferenceChange(ScrollOffsetKey.self) { offset in36shouldShowTitle = offset.y <= -32 // Fires constantly during scroll!37}3839// GOOD - only update when threshold crossed40.onPreferenceChange(ScrollOffsetKey.self) { offset in41let shouldShow = offset.y <= -3242if shouldShow != shouldShowTitle {43shouldShowTitle = shouldShow // Fires only when crossing threshold44}45}46```4748### 3. Pass Only What Views Need4950**Avoid passing large "config" or "context" objects.** Pass only the specific values each view needs.5152```swift53// Good - pass specific values54ThemeSelector(theme: config.theme)55FontSizeSlider(fontSize: config.fontSize)5657// Avoid - passing entire config (creates broad dependency)58ThemeSelector(config: config) // Notified of ALL config changes59```6061With `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.6263### 4. Use Equatable Views6465For views with expensive bodies, conform to `Equatable`:6667```swift68struct ExpensiveView: View, Equatable {69let data: SomeData7071static func == (lhs: Self, rhs: Self) -> Bool {72lhs.data.id == rhs.data.id // Custom equality check73}7475var body: some View {76// Expensive computation77}78}7980// Usage81ExpensiveView(data: data)82.equatable() // Use custom equality83```8485**Caution**: If you add new state or dependencies to your view, remember to update your `==` function!8687### 5. POD Views for Fast Diffing8889**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.9091```swift92// POD view - fastest diffing93struct FastView: View {94let title: String95let count: Int9697var body: some View {98Text("\(title): \(count)")99}100}101102// Non-POD view - uses reflection or custom equality103struct SlowerView: View {104let title: String105@State private var isExpanded = false // Property wrapper makes it non-POD106107var body: some View {108Text(title)109}110}111```112113**Advanced Pattern**: Wrap expensive non-POD views in POD parent views:114115```swift116// POD wrapper for fast diffing117struct ExpensiveView: View {118let value: Int119120var body: some View {121ExpensiveViewInternal(value: value)122}123}124125// Internal view with state126private struct ExpensiveViewInternal: View {127let value: Int128@State private var item: Item?129130var body: some View {131// Expensive rendering132}133}134```135136**Why**: The POD parent uses fast `memcmp` comparison. Only when `value` changes does the internal view get diffed.137138### 6. Lazy Loading139140Use lazy containers for large collections:141142```swift143// BAD - creates all views immediately144ScrollView {145VStack {146ForEach(items) { item in147ExpensiveRow(item: item)148}149}150}151152// GOOD - creates views on demand153ScrollView {154LazyVStack {155ForEach(items) { item in156ExpensiveRow(item: item)157}158}159}160```161162**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.163164> Source: "What's new in SwiftUI" (WWDC25, session 256)165166### 7. Task Cancellation167168Cancel async work when view disappears:169170```swift171struct DataView: View {172@State private var data: [Item] = []173174var body: some View {175List(data) { item in176Text(item.name)177}178.task {179// Automatically cancelled when view disappears180data = await fetchData()181}182}183}184```185186### 8. Debug View Updates187188**Use `Self._printChanges()` or `Self._logChanges()` to debug unexpected view updates.**189190```swift191struct DebugView: View {192@State private var count = 0193@State private var name = ""194195var body: some View {196#if DEBUG197let _ = Self._logChanges() // Xcode 15.1+: logs to com.apple.SwiftUI subsystem198#endif199200VStack {201Text("Count: \(count)")202Text("Name: \(name)")203}204}205}206```207208- `Self._printChanges()`: Prints which properties changed to standard output.209- `Self._logChanges()` (iOS 17+): Logs to the `com.apple.SwiftUI` subsystem with category "Changed Body Properties", using `os_log` for structured output.210211Both print `@self` when the view value itself changed and `@identity` when the view's persistent data was recycled.212213**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.214215### 9. Eliminate Unnecessary Dependencies216217**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.218219```swift220// Bad - broad dependency on entire model221struct ItemRow: View {222@Environment(AppModel.self) private var model223let item: Item224var body: some View { Text(item.name).foregroundStyle(model.theme.primaryColor) }225}226227// Good - narrow dependency228struct ItemRow: View {229let item: Item230let themeColor: Color231var body: some View { Text(item.name).foregroundStyle(themeColor) }232}233```234235**Avoid storing frequently-changing values in the environment.** Every write to *any* environment key forces every view that reads *any* key in that subtree to be checked. So a high-frequency value (scroll offset, window/container size, drag translation, per-frame animation progress, timer ticks, hover location) flowing into an `@Entry` or `.environment(\.key, value)` becomes an invalidation storm.236237Instead, hold the value in an `@Observable` model and expose a **coarsened** value — a boolean threshold rather than the raw measurement — so views invalidate only when crossing a meaningful boundary:238239```swift240@MainActor @Observable241final class ViewportModel {242var width: CGFloat = 0 { didSet { isWide = width > 600 } }243private(set) var isWide = false // readers invalidate only when this flips244}245```246247The same shape applies per row in a list: storing the raw offset on a model and having every row read it still invalidates all visible rows every frame. Give each item its own `@Observable` whose properties track only that item's derived state (e.g. `isVisible`), so each row invalidates at most twice (enter/leave) regardless of scroll speed — see item #10 below.248249For purely visual effects driven by scroll position (opacity, scale, rotation), prefer `scrollTransition` or `visualEffect(in:)` — they push the per-frame work to the renderer and skip body re-evaluation entirely. Reach for the `@Observable` + coarsening pattern only when the scroll-derived value must drive non-rendering logic (model updates, prefetches, sibling state).250251> Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306)252253### 10. @Observable Dependency Granularity254255**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.256257```swift258// BAD - all item views depend on the full favorites array259@Observable260class ModelData {261var favorites: [Landmark] = []262263func isFavorite(_ landmark: Landmark) -> Bool {264favorites.contains(landmark)265}266}267268struct LandmarkRow: View {269let landmark: Landmark270@Environment(ModelData.self) private var model271272var body: some View {273HStack {274Text(landmark.name)275if model.isFavorite(landmark) {276Image(systemName: "heart.fill")277}278}279}280}281282// GOOD - each item has its own observable view model283@Observable284class LandmarkViewModel {285var isFavorite: Bool = false286}287288struct LandmarkRow: View {289let landmark: Landmark290let viewModel: LandmarkViewModel291292var body: some View {293HStack {294Text(landmark.name)295if viewModel.isFavorite {296Image(systemName: "heart.fill")297}298}299}300}301```302303**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.304305> Source: "Optimize SwiftUI performance with Instruments" (WWDC25, session 306)306307### 11. Off-Main-Thread Closures308309**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.310311Closures that may run off the main thread:312- `Shape.path(in:)`313- `visualEffect` closure314- `Layout` protocol methods315- `onGeometryChange` transform closure316317```swift318// BAD - accessing @MainActor state directly319.visualEffect { content, geometry in320content.blur(radius: self.pulse ? 5 : 0) // Compiler error: @MainActor isolated321}322323// GOOD - capture the value324.visualEffect { [pulse] content, geometry in325content.blur(radius: pulse ? 5 : 0)326}327```328329> Source: "Explore concurrency in SwiftUI" (WWDC25, session 266)330331### 12. Common Performance Issues332333**Be aware of common performance bottlenecks in SwiftUI:**334335- View invalidation storms from broad state changes336- Unstable identity in lists causing excessive diffing337- Heavy work in `body` (formatting, sorting, image decoding)338- Layout thrash from deep stacks or preference chains339340**When performance issues arise**, suggest the user profile with Instruments (SwiftUI template) to identify specific bottlenecks.341342## Anti-Patterns343344### 1. Creating Objects in Body345346```swift347// BAD - creates new formatter every body call348var body: some View {349let formatter = DateFormatter()350formatter.dateStyle = .long351return Text(formatter.string(from: date))352}353354// GOOD - static or stored formatter355private static let dateFormatter: DateFormatter = {356let f = DateFormatter()357f.dateStyle = .long358return f359}()360361var body: some View {362Text(Self.dateFormatter.string(from: date))363}364```365366### 2. Heavy Computation in Body367368**Keep view body simple and pure.** Avoid side effects, dispatching, or complex logic.369370```swift371// BAD - sorts array every body call372var body: some View {373List(items.sorted { $0.name < $1.name }) { item in Text(item.name) }374}375376// GOOD - compute once, update via onChange or a computed property in the model377@State private var sortedItems: [Item] = []378379var body: some View {380List(sortedItems) { item in Text(item.name) }381.onChange(of: items) { _, newItems in382sortedItems = newItems.sorted { $0.name < $1.name }383}384}385```386387Move sorting, filtering, and formatting into models or computed properties. The `body` should be a pure structural representation of state.388389### 3. Unnecessary State390391```swift392// BAD - derived state stored separately393@State private var items: [Item] = []394@State private var itemCount: Int = 0 // Unnecessary!395396// GOOD - compute derived values397@State private var items: [Item] = []398399var itemCount: Int { items.count } // Computed property400```401402## Summary Checklist403404- [ ] State updates check for value changes before assigning405- [ ] Hot paths minimize state updates406- [ ] Pass only needed values to views (avoid large config objects)407- [ ] Large lists use `LazyVStack`/`LazyHStack`408- [ ] No object creation in `body`409- [ ] Heavy computation moved out of `body`410- [ ] Body kept simple and pure (no side effects)411- [ ] Derived state computed, not stored412- [ ] Use `Self._logChanges()` or `Self._printChanges()` to debug unexpected updates413- [ ] Equatable conformance for expensive views (when appropriate)414- [ ] Consider POD view wrappers for advanced optimization415- [ ] Consider using granular @Observable dependencies for list items (smaller observable units per row when it measurably reduces updates)416- [ ] Frequently-changing values not stored in the environment417- [ ] Sendable closures (Shape, visualEffect, Layout) capture values instead of accessing @MainActor state418