SwiftUI: anatomy of mistakes even seniors make
Working with various iOS projects over the years, I've often encountered different approaches to building views and the bugs that come with them. There were projects written in a rush before a deadline, "temporary" solutions living in production for years, and code that "works, so don't touch it." But when SwiftUI entered the game, it turned out that even experienced developers can fall into traps they didn't even know existed.
In this article, I wanted to highlight such pitfalls and mistakes. It's a deeper look at how the framework works under the hood and the mistakes that can cost you hours of debugging, or worse, go unnoticed until users start complaining about a laggy interface.
Most common signs that you have a SwiftUI performance problem
Before we dive into specific mistakes, check if you recognize any of these symptoms:
π΄ Scroll lags despite simple UI
A list with 50 elements shouldn't lag. If it does, views are probably recalculating on every frame.
π΄ Animations are choppy or "jump"
SwiftUI should smoothly interpolate changes. Choppy animations mean something is forcing a complete view rebuild instead of an update.
π΄ body recalculates hundreds of times per second
Add let _ = Self._printChanges() to your view. If the console is flooded with entries, you have a dependency problem.
π΄ List "loses" state or resets scroll position
This is a classic symptom of losing structural identity. Views are being destroyed and recreated instead of updated.
π΄ TextField loses focus while typing
Each letter causes the parent to rebuild, which destroys and recreates the TextField.
π΄ Tasks get cancelled "for no reason"
You're probably touching state before an async operation completes, which rebuilds the view and cancels the associated task.
π΄ App uses disproportionately high CPU while idle
Somewhere you have a timer, observer, or binding that triggers constant recalculation.
If you recognize even one of these symptoms, keep reading. You'll probably find the cause.
How SwiftUI really works
Before we get to the mistakes, we need to understand the fundamentals. SwiftUI is not "better UIKit." It's a completely different paradigm, a different approach that requires a shift in thinking.
View is not a view - it's a recipe for a view
In UIKit, UIView is an object living in memory, with a pointer that identifies it. You can mutate it, move it, hide it. In SwiftUI, View is a struct - a value, not a reference. It's not a view on the screen. It's a description of what the view should look like.
When SwiftUI renders the interface, it goes through three phases:
- Body evaluation - the framework calls your view's
bodyproperty, - Diffing - compares the new result with the previous one,
- Rendering - updates only the pixels that actually changed
Key observation: body evaluation does not mean rendering. SwiftUI can call body multiple times in a single animation frame, but the actual render on the GPU will happen only once. The framework is smart - it collects all changes and commits them together.
Dependency Graph - the heart of SwiftUI
SwiftUI maintains an internal dependency graph (AttributeGraph). Each view has its dependencies - @State, @Binding, @ObservedObject, @Environment. When any of them changes, SwiftUI knows exactly which views need to recalculate their body.
This sounds great in theory. In practice? One misplaced @ObservedObject can cause a cascade of unnecessary recalculations.
Mistake #1: the @ObservedObject epidemic
π Problem
struct ProductView: View {
@ObservedObject var viewModel = ProductViewModel() // β
var body: some View {
Text(viewModel.name)
}
}
β Why this is a mistake
@ObservedObject doesn't manage the object's lifecycle. Every time the parent recalculates its body, SwiftUI creates a new instance of ProductView - and with it, a new ProductViewModel.
This isn't obvious because the view struct is "lightweight." But ViewModel initialization can be heavy: network requests in init, Combine subscriptions, cache setup.
β How to fix it
struct ProductView: View {
@StateObject private var viewModel = ProductViewModel() // β
var body: some View {
Text(viewModel.name)
}
}
@StateObject creates the object only once and maintains it throughout the view's lifecycle. Use @ObservedObject only when the object is injected from outside.
β οΈ What happens if you ignore this
- State disappears on every parent refresh,
- Network requests get duplicated,
- Memory leaks - old ViewModels may not be released immediately,
- Combine subscriptions multiply, leading to "ghost updates"
π― Pitfall when migrating to @Observable
Since iOS 17, we have the @Observable macro, which was supposed to be a drop-in replacement for ObservableObject. But there's a small, subtle difference:
// ObservableObject - @autoclosure, lazy init
@StateObject private var viewModel = ProductViewModel()
// @Observable - regular value, eager init!
@State private var viewModel = ProductViewModel()
@StateObject uses @autoclosure - the object is created only once, lazily. @State initializes the value on every view struct creation, and only then SwiftUI "restores" the saved state.
In practice: with @State and @Observable, you can have multiple instances of your model hanging around in memory. Jesse Squires described a case where these "ghost" objects were still listening to notifications and overwriting data in UserDefaults Link to article.
Mistake #2: ObservableObject with too many @Published
π Problem
class SignUpViewModel: ObservableObject {
@Published var email: String = ""
@Published var password: String = ""
@Published var confirmPassword: String = ""
@Published var acceptedTerms: Bool = false
@Published var isLoading: Bool = false
@Published var errorMessage: String? = nil
}
β Why this is a mistake
You have a form with 6 fields. The user types a letter in the email field. What happens?
Every view observing this ViewModel gets notified. Not "views using email." All of them. ObservableObject doesn't distinguish which property changed - it sends a single objectWillChange signal.
β How to fix it
Option 1: Granular binding
struct SignUpView: View {
@StateObject var viewModel = SignUpViewModel()
var body: some View {
VStack {
EmailField(email: $viewModel.email)
PasswordField(password: $viewModel.password)
}
}
}
struct EmailField: View {
@Binding var email: String
var body: some View {
TextField("Email", text: $email)
}
}
Now EmailField only gets an update when email changes.
Option 2: Migrate to @Observable (iOS 17+)
@Observable
class SignUpViewModel {
var email: String = ""
var password: String = ""
}
@Observable tracks property access at the individual field level. If a view only reads email, then only an email change will cause a body recalculation.
β οΈ What happens if you ignore this
- Every keystroke in any field refreshes all form fields,
- With 10 views observing the ViewModel, typing "hello@email.com" means 160 unnecessary body recalculations,
- Animations stutter because the CPU is busy with diffing,
- On older devices, the form becomes noticeably laggy.
Mistake #3: conditional view modifiers
π Problem
extension View {
@ViewBuilder
func applyIf<Content: View>(
_ condition: Bool,
transform: (Self) -> Content
) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
// Usage
Text("Hello")
.applyIf(isHighlighted) { view in
view.background(Color.yellow)
}
β Why this is a mistake
It looks elegant, but the problem lies in what happens under the hood.
SwiftUI uses structural identity. An if statement in @ViewBuilder creates _ConditionalContent<A, B> - a view that switches between two different view types.
When isHighlighted changes from false to true:
- SwiftUI destroys the view from the
falsebranch, - Creates from scratch the view from the
truebranch, - Fires
onDisappear+onAppear, - Loses all internal state.
β How to fix it
Use the condition inside the modifier, not a conditional modifier:
// β Wrong - two different view types
if isHighlighted {
Text("Hello").background(Color.yellow)
} else {
Text("Hello")
}
// β
Correct - same type, different value
Text("Hello")
.background(isHighlighted ? Color.yellow : Color.clear)
SwiftUI has "inert modifiers" - .opacity(1), .padding(0), .background(Color.clear), which don't change the view's structure.
β οΈ What happens if you ignore this
- TextField loses focus when the condition changes,
- Scroll position resets to zero,
- Animations "jump" instead of transitioning smoothly,
@Stateinside the view resets to its initial value,onAppearfires multiple times, potentially triggering duplicate network requests.
Mistake #4: closure capturing in body
π Problem
struct ItemList: View {
@ObservedObject var store: ItemStore
var body: some View {
List(store.items) { item in
Button(item.name) {
store.select(item) // Closure captures `store`
}
}
}
}
β Why this is a mistake
SwiftUI uses reflection to compare view properties and decide whether body needs to be recalculated. The problem? Closures are practically incomparable.
When the parent view refreshes, SwiftUI sees a new closure (which looks identical but is technically a new instance) and concludes: "something changed, I need to recalculate body."
β How to fix it
Extract the closure to a separate view with comparable properties:
struct ItemList: View {
@ObservedObject var store: ItemStore
var body: some View {
List(store.items) { item in
ItemRow(item: item, onTap: store.select)
}
}
}
struct ItemRow: View {
let item: Item
let onTap: (Item) -> Void
var body: some View {
Button(item.name) {
onTap(item)
}
}
}
Now ItemRow has comparable properties (item conforms to Equatable), and the closure is passed as a method reference, not created anew.
β οΈ What happens if you ignore this
- Every list row recalculates
bodyon every change in the parent, - With 100 rows at 60 FPS, that's potentially 6000
bodyrecalculations per second, - Scroll hitches - frames are dropped because the CPU can't keep up,
- Battery drains much faster than it should.
Mistake #5: not knowing about EquatableView
π Problem
You have a complex view with a lot of rendering logic. It recalculates body every time the parent refreshes, even though its own data hasn't changed.
struct ExpensiveChartView: View {
let data: ChartData
let configuration: ChartConfig
var body: some View {
// Complex calculations, many layers...
}
}
β Why this is a mistake
SwiftUI by default compares all view properties through reflection. For simple types, this is fast. But for complex structures, nested arrays, or objects with closures, diffing can be more expensive than recalculating the body itself.
β How to fix it
struct ExpensiveChartView: View, Equatable {
let data: ChartData
let configuration: ChartConfig
static func == (lhs: Self, rhs: Self) -> Bool {
// Custom, cheap comparison logic
lhs.data.id == rhs.data.id &&
lhs.data.version == rhs.data.version
}
var body: some View {
// Complex calculations...
}
}
// Usage
ExpensiveChartView(data: chartData, configuration: config)
.equatable() // π Magic modifier
.equatable() tells SwiftUI: "use my == implementation instead of the default diffing." If you return true, body will not be recalculated.
Airbnb published a case study: adding
.equatable()reduced scroll hitches by 15% on their main search screen Link to article
β οΈ What happens if you ignore this
- The view recalculates on every parent refresh, even when data hasn't changed,
- Complex views (charts, maps, rich text) may drop frames,
- Battery drains faster from constant, unnecessary calculations.
Note: EquatableView doesn't work well with internal property wrappers like @State. Such properties don't participate in the comparison.
Mistake #6: task modifier and unexpected cancellation
π Problem
struct ProfileView: View {
@StateObject var viewModel = ProfileViewModel()
var body: some View {
List { /* ... */ }
.refreshable {
viewModel.isRefreshing = true // β
await viewModel.refresh()
viewModel.isRefreshing = false
}
}
}
β Why this is a mistake
.refreshable creates a Task bound to the view. When the view rebuilds, the task gets cancelled. And what causes a view rebuild? A @Published property change!
The sequence:
- You set
isRefreshing = true, - ViewModel sends
objectWillChange, - The view rebuilds,
- The task gets cancelled,
- The network request never completesβ
β How to fix it
Don't touch state before the async operation completes:
.refreshable {
await viewModel.refresh() // Execute the operation first
// Update state in refresh(), AFTER completion
}
// In ViewModel:
func refresh() async {
// Don't set isRefreshing here!
let data = await fetchData()
self.items = data
// Now you can update UI state
}
Or use task(id:) for automatic management:
.task(id: selectedCategory) {
await loadItems(for: selectedCategory)
}
When selectedCategory changes, SwiftUI automatically cancels the previous task and starts a new one.
β οΈ What happens if you ignore this
- Pull-to-refresh "doesn't work" - the spinner spins forever,
- Data doesn't update despite a correct API,
- Users report bugs like "the app froze",
- Debugging is a nightmare because the problem is intermittent.
Mistake #7: the id() modifier - a double-edged sword
π Problem
List(items) { item in
ItemRow(item: item)
.id(UUID()) // β Disaster!
}
β Why this is a mistake
id() gives the view an explicit identity. When the ID changes, SwiftUI treats it as a completely new view - destroys the old one, creates a new one from scratch.
UUID() generates a new identifier on every call. So on every list refresh, every row is "new."
β How to fix it
ID must be stable, tied to data, not to rendering:
List(items) { item in
ItemRow(item: item)
.id(item.id) // β
Stable ID from model
}
When id() is actually needed (forcing a state reset):
TextField("Search", text: $query)
.id(resetCounter) // Changing resetCounter clears the text
β οΈ What happens if you ignore this
- Scroll "jumps" - the list "jumps" on every refresh,
- Animations are chaotic - SwiftUI doesn't know what to animate,
- State loss - expanded cells collapse, selection disappears,
- Massive memory churn - constant allocations and deallocations,
- On lists with images: flickering, because images are loaded anew.
Mistake #8: expensive operations in body
π Problem
var body: some View {
let filtered = items.filter { $0.category == selectedCategory }
.sorted { $0.date > $1.date }
List(filtered) { item in
ItemRow(item: item)
}
}
β Why this is a mistake
body can be called dozens of times per second during animations. Filtering and sorting on every call? That's an O(n log n) operation, potentially executed 60 times per second.
β How to fix it
Option 1: Computed property (for simple cases)
struct ItemListView: View {
let items: [Item]
let selectedCategory: Category
private var filteredItems: [Item] {
items.filter { $0.category == selectedCategory }
.sorted { $0.date > $1.date }
}
var body: some View {
List(filteredItems) { item in
ItemRow(item: item)
}
}
}
Option 2: Cache in ViewModel (for complex cases)
@Observable
class ItemListViewModel {
var items: [Item] = []
var selectedCategory: Category = .all
// @Observable makes this effectively cached
var filteredItems: [Item] {
items.filter { $0.category == selectedCategory }
.sorted { $0.date > $1.date }
}
}
Option 3: Explicit memoization
@State private var filteredItems: [Item] = []
var body: some View {
List(filteredItems) { item in
ItemRow(item: item)
}
.onChange(of: items) { updateFilteredItems() }
.onChange(of: selectedCategory) { updateFilteredItems() }
}
private func updateFilteredItems() {
filteredItems = items.filter { $0.category == selectedCategory }
.sorted { $0.date > $1.date }
}
β οΈ What happens if you ignore this
- Animations stutter - the CPU is busy sorting instead of rendering,
- Scroll hitches on lists,
- Increased battery drain,
- On larger datasets (1000+ items), the app becomes unusable.
Mistake #9: AnyView - type erasure at all costs
π Problem
func makeView(for type: ContentType) -> AnyView {
switch type {
case .text: return AnyView(TextView())
case .image: return AnyView(ImageView())
case .video: return AnyView(VideoView())
}
}
β Why this is a mistake
AnyView erases the view's type at compile time. SwiftUI loses information needed to optimize diffing; it doesn't know what type of view is inside, so it has to assume the worst.
β How to fix it
Use @ViewBuilder:
@ViewBuilder
func makeView(for type: ContentType) -> some View {
switch type {
case .text: TextView()
case .image: ImageView()
case .video: VideoView()
}
}
@ViewBuilder generates _ConditionalContent, which preserves type information. SwiftUI can intelligently switch between branches.
β οΈ What happens if you ignore this
- Every type change forces a complete hierarchy rebuild,
- Transition animations don't work properly,
- Performance degrades proportionally to hierarchy depth,
- Tests in Instruments will show disproportionately large time spent on diffing.
Mistake #10: environment objects - global evil
π Problem
@main
struct MyApp: App {
@StateObject var appState = AppState() // 50 properties
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appState)
}
}
}
β Why this is a mistake
AppState with 50 properties, injected at the very top of the hierarchy. Every change to any property sends objectWillChange to all views that observe it.
Even if ProfileView only uses appState.user, it will get notified when appState.cart, appState.notifications, or appState.theme changes.
β How to fix it
Option 1: Split one large AppState into specialized objects
@StateObject var userSettings = UserSettings()
@StateObject var cartManager = CartManager()
@StateObject var navigationState = NavigationState()
ContentView()
.environmentObject(userSettings)
.environmentObject(cartManager)
.environmentObject(navigationState)
Option 2: Dedicated Environment Keys for global values
private struct ThemeKey: EnvironmentKey {
static let defaultValue = Theme.light
}
extension EnvironmentValues {
var theme: Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
// Usage
@Environment(\.theme) var theme
Option 3: Migrate to @Observable (iOS 17+)
With @Observable, views only react to properties they actually read. Even a large state object isn't a problem.
β οΈ What happens if you ignore this
- Every state change potentially refreshes hundreds of views,
- The app gets slower as the codebase grows,
- Debugging becomes nearly impossible - everything affects everything,
- Memory pressure grows from constantly creating new views.
Debugging - how to find the source of the problem?
Self._printChanges()
var body: some View {
let _ = Self._printChanges()
// ...
}
This is an undocumented API that prints to the console what caused the body recalculation:
ContentView: @self changed.
ContentView: _selectedItem changed.
β οΈ Remove it before release, as it's an internal API.
Instruments - SwiftUI template
- Product β Profile (βI)
- Select the SwiftUI template
- Record the interaction
You'll see:
- View Body: how many times each view recalculated body,
- Core Animation Commits: actual renders on the GPU,
- Time Profiler: where you're spending time,
Interpretation:
- β View Body high, Core Animation low β SwiftUI is optimizing effectively,
- β Both high β you have a real performance problem,
Random color debugging
extension View {
func debugRandomBackground() -> some View {
self.background(Color(
red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1)
))
}
}
Add it to a view and observe. If the color changes without user interaction, the view is being unnecessarily recalculated.
Summary
SwiftUI is a powerful framework, but it requires a deep understanding of how it works under the hood. Most performance problems come down to:
| Problem | Solution |
|---|---|
| Dependencies too broad | Granular @Binding, migration to @Observable |
| Loss of structural identity | Conditions inside modifiers, stable IDs |
| Unnecessary work in body | Cache, computed properties, memoization |
| Not knowing the tools | EquatableView, task(id:), @ViewBuilder |
Remember: SwiftUI is declarative, but you must declare wisely. The framework will do its job, as long as you don't get in its way.

RafaΕ Dubiel
Senior iOS Developer with 8+ years of experience building mobile applications. Passionate about clean architecture, Swift, and sharing knowledge with the community.
Share this article: