Wes Matlock

Optimizing SwiftUI: Reducing Body Recalculation and Minimizing @State Updates

Having been involved in iOS development since the release of the first SDK, I’ve witnessed the transformation of Apple’s UI frameworks from…


Optimizing SwiftUI: Reducing Body Recalculation and Minimizing @State Updates

Having been involved in iOS development since the release of the first SDK, I’ve witnessed the transformation of Apple’s UI frameworks from UIKit’s imperative approach to SwiftUI’s declarative paradigm. SwiftUI has streamlined many aspects of UI development, but it also introduces new challenges, especially when it comes to performance optimization.

In this article, I want to delve into the technical nuances of optimizing SwiftUI applications. Drawing from my extensive experience, I’ll focus on reducing unnecessary body recalculations and minimizing @State updates to enhance app performance.

Understanding SwiftUI’s Rendering Mechanism

The Evolution to Declarative UI

Back in the early days of iOS development, building interfaces involved meticulously managing view hierarchies and state changes with UIKit. With SwiftUI, Apple introduced a declarative syntax that abstracts much of the complexity, allowing developers to define what the UI should look like at any given state.

The Mechanics of View Rendering

In SwiftUI, every view conforms to the View protocol and provides a computed body property:

protocol View {  
  associatedtype Body : View  
  @ViewBuilder var body: Self.Body { get }  
}

Whenever a piece of state that a view depends on changes, SwiftUI:

  1. Invalidates the affected views.

  2. Recomputes their body properties.

  3. Diffs the new view hierarchy against the old one.

  4. Applies the minimal set of updates to the UI.

Understanding this cycle is crucial for optimization. Early in my SwiftUI journey, I noticed performance hiccups in complex views, which led me to investigate how state changes propagate through the view hierarchy.

Reducing Body Recalculations

Embracing Immutability in View Structs

Lessons from Mutable Properties

In the initial stages of working with SwiftUI, I sometimes found it tempting to include mutable properties within view structs for convenience. However, I quickly realized that this could lead to unpredictable behavior and performance issues.

Example:

struct ContentView: View {  
  var mutableProperty = MutableObject()  
  
  var body: some View {  
    Text("Hello, World!")  
  }  
}

Here, changes to mutableProperty don’t trigger a view update because SwiftUI isn’t observing it. This can cause the UI to become out of sync with the underlying data, leading to hard-to-debug issues.

My Best Practices

  • Stick to Immutable Properties: I ensure that all properties within my view structs are immutable unless they are explicitly state properties.
  • Use Property Wrappers: For any property that needs to change and affect the UI, I use property wrappers like @State, @ObservedObject, or @EnvironmentObject.

Avoiding Heavy Computations in the body Property

Realizations About Computation Overhead

Early on, I encountered sluggish UI updates and traced the issue back to expensive computations within the body property. Performing intensive tasks here can severely impact performance because body can be recomputed multiple times per second.

Practical Example

Inefficient Approach:

struct ContentView: View {  
  var body: some View {  
    let processedData = performExpensiveOperation()  
    return Text("Result: \(processedData)")  
  }  
  
  func performExpensiveOperation() -> String {  
    // Simulate a heavy computation  
    return (0...1_000_000).map { "\($0)" }.joined()  
  }  
}

Each time body is recomputed, performExpensiveOperation() runs, causing noticeable lag.

Optimized Approach:

struct ContentView: View {  
  let processedData: String  
  
  var body: some View {  
    Text("Result: \(processedData)")  
  }  
}  
  
// Usage  
let processedData = performExpensiveOperation()  
ContentView(processedData: processedData)

By moving the computation outside of body, we prevent unnecessary recalculations.


Utilizing Lazy Containers Effectively

Discovering Lazy Stacks

In building apps with extensive lists, I noticed performance drops when using VStack inside ScrollView. Switching to LazyVStack made a significant difference.

Technical Details

  • VStack: Instantiates all child views immediately, which can be costly for large datasets.
  • LazyVStack: Only creates views as they come into view, conserving resources.

Example:

ScrollView {  
  LazyVStack {  
    ForEach(0..<10000) { index in  
      Text("Item \(index)")  
    }  
  }  
}

This approach drastically reduces the initial load time and memory footprint.


Leveraging View Identity for Efficient Updates

Understanding Identity in SwiftUI

SwiftUI uses view identity to manage updates efficiently. By ensuring that views have stable and unique identifiers, we help SwiftUI minimize unnecessary re-renders.

Practical Implementation

In one of my projects, I had a list of items that frequently updated. By providing an id, I improved performance:

ForEach(items, id: \.id) { item in  
  Text(item.name)  
}

This way, SwiftUI can track each item individually, only updating those that have changed.

Minimizing @State Update

Being Judicious with @State

The Impact of Overusing @State

I learned that excessive use of @State can lead to performance issues due to frequent view invalidations. Initially, I used @State for any property that could change, but this wasn’t efficient.

Inefficient Example:

struct ContentView: View {  
  @State private var dataModel = DataModel()  
  
  var body: some View {  
    Text("Hello, World!")  
  }  
}

Changes to dataModel trigger a view update, even though the UI doesn’t depend on it.

Optimized Strategy

  • Use @State for UI-Related Properties Only: I reserve @State for properties that, when changed, should update the UI.
  • Externalize Non-UI State: For data models, I use ObservableObject and manage them outside the view.

Passing State with @Binding

Two-Way Data Flow

When I needed to share state between parent and child views without redundant properties, @Binding proved invaluable.

Example:

struct ParentView: View {  
  @State private var isOn = false  
  
  var body: some View {  
    ToggleView(isOn: $isOn)  
  }  
}  
  
struct ToggleView: View {  
  @Binding var isOn: Bool  
  
  var body: some View {  
    Toggle("Toggle", isOn: $isOn)  
  }  
}

This pattern ensures that both views stay in sync without unnecessary state duplication.

Debouncing Rapid State Changes

Managing High-Frequency Updates

In an app with a search feature, I noticed that typing rapidly caused performance issues due to constant state updates. Implementing debouncing helped mitigate this.

Implementation:

class ViewModel: ObservableObject {  
  @Published var searchText = "" {  
    didSet {  
      debounceSearch(text: searchText)  
    }  
  }  
  
  private var debounceTimer: Timer?  
  
  private func debounceSearch(text: String) {  
    debounceTimer?.invalidate()  
    debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in  
      self.performSearch(query: text)  
    }  
  }  
  
  private func performSearch(query: String) {  
    // Execute search logic  
  }  
}

By delaying the search execution, we reduce the number of unnecessary operations, improving responsiveness.


Practical Example: Building an Optimized Counter App

Inspiration for the App

To illustrate these optimization techniques, let’s build a simple counter app. This app reflects a pattern I’ve used in various projects to maintain efficiency.

Detailed Implementation

ContentView.swift

struct ContentView: View {  
  @State private var count = 0  
  
  var body: some View {  
    VStack {  
      CountDisplayView(count: count)  
      IncrementButton {  
        count += 1  
      }  
    }  
    .padding()  
  }  
}
  • Single Source of Truth: count is the only state property, reducing complexity.
  • Stateless Subviews: CountDisplayView and IncrementButton receive data and actions, avoiding their own state.

CountDisplayView.swift

struct CountDisplayView: View {  
  let count: Int  
  
  var body: some View {  
    Text("Count: \(count)")  
      .font(.largeTitle)  
      .padding()  
  }  
}
  • Immutable Properties: Using let ensures the view remains stateless and doesn’t trigger unnecessary updates.

IncrementButton.swift

struct IncrementButton: View {  
  let action: () -> Void  
  
  var body: some View {  
    Button(action: action) {  
      Text("Increment")  
        .font(.headline)  
        .padding()  
    }  
  }  
}
  • Action Closure: Passing the action keeps the button stateless and reusable.

Optimization Breakdown

  • Efficient State Management: Only ContentView manages state, reducing the scope of state changes.
  • Minimal Re-Renders: Stateless subviews only update when their inputs change.
  • Clean Separation: By separating views and logic, the code is more maintainable and performant.

Advanced Techniques

Implementing EquatableView for Performance Gains

Personal Experience with EquatableView

In complex views with large data models, I found that conforming to Equatable and wrapping views with EquatableView can prevent unnecessary updates.

Implementation:

struct MyEquatableView: View, Equatable {  
  let data: DataModel  
  
  static func == (lhs: MyEquatableView, rhs: MyEquatableView) -> Bool {  
    lhs.data == rhs.data  
  }  
  
  var body: some View {  
    // View content  
  }  
}  
  
struct ContentView: View {  
  var data: DataModel  
  
  var body: some View {  
    EquatableView(content: MyEquatableView(data: data))  
  }  
}
  • Equality Check: SwiftUI skips rendering if data hasn’t changed, improving performance.

Considerations

  • Data Conformance: Ensure DataModel conforms to Equatable.
  • Performance Trade-Off: The equality check should be less costly than a re-render.

Strategic Use of onChange

Avoiding Side Effects in body

In my early SwiftUI code, I sometimes included side effects within the body, which led to unpredictable behavior.

Optimized Approach:

struct ContentView: View {  
  @State private var username = ""  
  
  var body: some View {  
    TextField("Username", text: $username)  
      .onChange(of: username) { newValue in  
        validateUsername(newValue)  
      }  
  }  
  
  func validateUsername(_ username: String) {  
    // Validation logic  
  }  
}
  • Clean body: Keeping side effects out of body ensures that view updates are predictable.
  • Reactive Updates: onChange allows us to react to state changes without impacting the view’s rendering.

Memoization for Expensive Computations

Applying Memoization in Practice

In cases where views depend on expensive computations, I use memoization to cache results.

Implementation:

struct ContentView: View {  
  let data: [DataModel]  
  @StateObject private var cache = DataCache()  
  
  var body: some View {  
    let processedData = cache.getProcessedData(for: data)  
  
    return List(processedData) { item in  
      Text(item.name)  
    }  
  }  
}  
  
class DataCache: ObservableObject {  
  private var cache = [Int: [ProcessedDataModel]]()  
  
  func getProcessedData(for data: [DataModel]) -> [ProcessedDataModel] {  
    let key = data.hashValue  
    if let cachedData = cache[key] {  
      return cachedData  
    } else {  
      let processedData = processData(data)  
      cache[key] = processedData  
      return processedData  
    }  
  }  
  
  private func processData(_ data: [DataModel]) -> [ProcessedDataModel] {  
    // Expensive processing  
  }  
}
  • StateObject Persistence: Using @StateObject ensures the cache persists across view updates.
  • Hash-Based Caching: We use the hash value of the data as a key for caching.

Benefits

  • Performance Boost: Reduces redundant computations during view updates.
  • Responsive UI: Improves the app’s responsiveness, especially with complex data processing.

Additional Technical Insights

Navigating SwiftUI’s View Lifecycle

The Transience of View Instances

One key insight I’ve gained is understanding that SwiftUI recreates view structs frequently. This means any transient data stored in plain properties can be lost between updates.

Practical Implications

  • Use @State for Persistence: Any data that needs to persist across updates should be stored with a property wrapper like @State.
  • Avoid Relying on Init-Only Properties: Since views can be recreated, relying on initializer properties for state management can lead to issues.

The Power of Property Wrappers

Understanding Their Impact

Property wrappers like @State, @Binding, and @ObservedObject are more than syntactic sugar — they control how SwiftUI observes and responds to data changes.

My Approach

  • @State for Local State: I use @State for simple, view-specific state.
  • @ObservedObject and @EnvironmentObject for Shared State: For state that needs to be shared across multiple views, I rely on these wrappers.
  • @StateObject for Persistent Objects: When I need an object to persist for the lifetime of the view, @StateObject is my go-to.

Optimizing Animations

Lessons Learned

Animations can enhance user experience but can also introduce performance overhead if not managed carefully.

Strategies

  • Limit Scope: Apply animations only to the views that need them.
  • Control Animation Timing: Use explicit animations and control their duration and delay.
  • Disable Unnecessary Animations: Sometimes, setting .animation(nil) on a view helps prevent unwanted animations that can cause jank.

Efficient List Rendering

Ensuring Smooth Scrolling

In apps with extensive lists, I focus on optimizing data loading and view updates.

Techniques

  • Use Identifiable Data Models: Ensuring that data models conform to Identifiable helps SwiftUI manage lists efficiently.
  • Asynchronous Data Loading: I use Combine or async/await to load data incrementally, improving perceived performance.
  • Prefetching Data: Implementing data prefetching helps maintain smooth scrolling experiences.

Conclusion

From the early days of iOS development to the advent of SwiftUI, optimizing app performance has always been a crucial aspect of delivering a great user experience. SwiftUI introduces new paradigms that require us to rethink how we manage state and structure our views.

  • Embracing immutability in view structs.
  • Avoiding heavy computations within the body.
  • Utilizing lazy containers and understanding view identity.
  • Minimizing @State usage and leveraging @Binding.
  • Implementing advanced techniques like EquatableView and memoization.

We can build SwiftUI apps that are both efficient and maintainable.

My journey with SwiftUI has been one of continuous learning and adaptation. By sharing these insights, I hope to help fellow developers navigate the intricacies of SwiftUI performance optimization, drawing from my experiences since the inception of iOS development.

Remember, performance optimization isn’t just about making apps faster; it’s about creating smooth, responsive experiences that delight users. With careful attention to how we manage state and structure our views, we can unlock the full potential of SwiftUI.

If you want to learn more about native mobile development, you can check out the other articles I have written here: https://medium.com/@wesleymatlock

🚀 Happy coding! 🚀

By Wesley Matlock on October 17, 2024.

Canonical link

Exported from Medium on May 10, 2025.

Written on October 17, 2024