Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 078 / 100

Common SwiftUI mistakes: real-world lessons and examples

Bugs and slowdowns to avoid.

Expona Admin7 min read

Introduction

After 78 days of building with Swift and SwiftUI, you've likely shipped features that work. But working isn't the same as performing well, and performant isn't the same as maintainable. We've seen production apps grind to a halt and seen teams spend weeks debugging issues that could have been caught early. This article covers common SwiftUI mistakes: real-world lessons and examples drawn from e-commerce platforms, CRM dashboards, and ride-hailing apps where performance and reliability matter.

Heavy Work Inside Body

The body property in SwiftUI isn't a place to do serious work. It's called frequently—often multiple times per frame during animations or state updates—and any expensive operation there compounds into sluggish UIs.

We worked on a travel app that fetched geocoding data, parsed JSON, and performed string transformations directly in the body of a row view. On a list of 100 destinations, scrolling was visibly janky. Here's what that looked like:

swift
struct DestinationRow: View {
  let data: String
  
  var body: some View {
    VStack {
      // BAD: Heavy work in body
      let coordinates = parseCoordinates(data)
      let formatted = formatAddressString(coordinates)
      let imageURL = fetchImageURL(formatted)
      
      Text(formatted)
      AsyncImage(url: imageURL)
    }
  }
}

Every time this view recomputes, all three functions run again. If the parent updates for any reason, the child recalculates needlessly. The fix is to move work upstream into a view model or to use computed properties and lazy evaluation:

swift
struct DestinationRow: View {
  let data: String
  @State private var viewModel: RowViewModel?
  
  var body: some View {
    VStack {
      if let vm = viewModel {
        Text(vm.formatted)
        AsyncImage(url: vm.imageURL)
      }
    }
    .onAppear {
      Task {
        viewModel = await RowViewModel(from: data)
      }
    }
  }
}

@MainActor
class RowViewModel: ObservableObject {
  let formatted: String
  let imageURL: URL?
  
  init(from data: String) async {
    let coordinates = parseCoordinates(data)
    self.formatted = formatAddressString(coordinates)
    self.imageURL = try? await fetchImageURL(self.formatted)
  }
}

The second approach defers work to initialization or to the view model's lifecycle, keeping the body lean and fast. For truly immutable data, consider pre-computing in your data layer entirely.

Unstable IDs in ForEach

ForEach requires stable identifiers. If your id changes between renders or isn't unique, SwiftUI can't track which view corresponds to which data item. This causes animation glitches, incorrect state binding, and mysteriously duplicated or missing rows.

A CRM team ran into this with a contact list that sorted by last-interacted date. They used index as the identifier without thinking:

swift
struct ContactList: View {
  @State var contacts: [Contact]
  
  var body: some View {
    List {
      // BAD: Index is not stable when list reorders
      ForEach(contacts.indices, id: \.self) { index in
        ContactRow(contact: contacts[index])
      }
    }
  }
}

When the list reordered, the index for each contact changed, and SwiftUI lost track of which row belonged to whom. Expand/collapse state got attached to the wrong contact. The fix is to use the contact's unique identifier:

swift
struct ContactList: View {
  @State var contacts: [Contact]
  
  var body: some View {
    List {
      // GOOD: Use stable, unique id
      ForEach(contacts, id: \.id) { contact in
        ContactRow(contact: contact)
      }
    }
  }
}

If your model doesn't have an id field, add one. A UUID or server-assigned identifier is always better than positional data. Never use derived or computed values as ids unless they're guaranteed immutable for the lifetime of that view hierarchy.

Creating Observable Objects Inside Body

Instantiating an ObservableObject or StateObject inside body is a common trap. The object gets recreated on every render, losing state and wasting memory. We saw this in a trading app's real-time price feed:

swift
struct PriceWidget: View {
  let symbol: String
  
  var body: some View {
    // BAD: New observer created on every body render
    let priceObserver = PriceObserver(symbol: symbol)
    
    VStack {
      Text("Price: \(priceObserver.price)")
    }
    .onAppear {
      Task {
        await priceObserver.startFetching()
      }
    }
  }
}

Each render call creates a new observer, the old one gets deallocated, the task cancels, and no data flows. The price never updates. Use @StateObject to own the lifecycle of an observable object:

swift
struct PriceWidget: View {
  let symbol: String
  @StateObject private var priceObserver: PriceObserver
  
  init(symbol: String) {
    self.symbol = symbol
    _priceObserver = StateObject(wrappedValue: PriceObserver(symbol: symbol))
  }
  
  var body: some View {
    VStack {
      Text("Price: \(priceObserver.price)")
    }
    .onAppear {
      Task {
        await priceObserver.startFetching()
      }
    }
  }
}

SwiftUI retains the StateObject across renders, so the observer lives as long as the view does. Tasks complete, data flows, and prices update. If you're receiving an observable object from a parent, use @ObservedObject. If the object is injected via the environment, use @EnvironmentObject. Each has its place.

Overusing @EnvironmentObject

@EnvironmentObject is convenient for global app state. Too convenient. We've seen apps where half the views were reading from a massive EnvironmentObject, making dependencies invisible and testing nearly impossible.

An e-commerce app had a monolithic AppState environment object passed down from the root:

swift
@MainActor
class AppState: ObservableObject {
  @Published var user: User?
  @Published var cart: [CartItem] = []
  @Published var recommendations: [Product] = []
  @Published var notifications: [Notification] = []
  @Published var darkMode: Bool = false
  // ... 20 more properties
}

struct ProductDetail: View {
  @EnvironmentObject var appState: AppState
  
  var body: some View {
    // Which properties does this view actually need?
    // Hard to know. Hard to test.
  }
}

Every view that needed even one piece of app state reached into the environment. Refactoring AppState broke unknown dependents. Testing required setting up the entire object. The healthier pattern is to extract smaller, focused objects and pass them explicitly:

swift
@MainActor
class CartManager: ObservableObject {
  @Published var items: [CartItem] = []
}

@MainActor
class UserManager: ObservableObject {
  @Published var user: User?
}

struct ProductDetail: View {
  @StateObject private var cartManager: CartManager
  @ObservedObject var userManager: UserManager
  
  init(cartManager: CartManager, userManager: UserManager) {
    self._cartManager = StateObject(wrappedValue: cartManager)
    self.userManager = userManager
  }
  
  var body: some View {
    // Clear dependencies. Easy to test.
  }
}

Reserve EnvironmentObject for truly global, rarely-changing settings like theme or localization. For feature-specific state, pass objects explicitly. Your code becomes more readable, more testable, and easier to refactor.

Common Mistakes at a Glance

Mistake Impact Fix
Heavy computation in body Frame drops, janky scrolling Move to view model, compute on init or in tasks
Index-based ForEach ids Broken animations, mismatched state Use stable, unique model identifiers
ObservableObject created in body State loss, tasks cancelled, no updates Use @StateObject, initialize in init()
EnvironmentObject everywhere Hidden dependencies, hard to test Pass objects explicitly, limit EnvironmentObject to global config

Key Takeaways

  • Keep the body property thin and fast. Offload heavy work to view models, initializers, or tasks that run once per lifecycle event.
  • Always use stable, unique identifiers in ForEach. Never rely on index position when data can reorder.
  • Use @StateObject to own observable objects within a view. Don't recreate them on every render.
  • Prefer explicit parameter passing over @EnvironmentObject for feature-specific state. Keep dependencies visible and testable.
  • Profile early and often. Use Instruments to spot frame drops and excessive view recomputation before they ship.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles