Common SwiftUI Pitfalls

- Vincent Friedrich
- |
- , 11 min read

SwiftUI is lightweight and simple, but also prone to being held wrong. Are you just starting out with SwiftUI, or are you burnt out from using SwiftUI and need a fellow grumbler? I'm sharing some things I learned in the past, and try making your life easier.
Disclaimer
I don’t work at Apple - I can only be good at reading between the lines of Apple’s documentation, try drawing the right conclusions from my own mistakes and exchanging knowledge with the community.
The list is not complete - there are very certainly pitfalls I’m not aware of.
In this post, I will focus on some common and some specific things from my personal experience. There are already many great articles out there that summarize common pitfalls.
Here are some of them:
- Hacking with Swift: 8 Common SwiftUI Mistakes - and how to fix them
- Fatbobman: Common Misconceptions About SwiftUI
- EffectUI: The Pitfalls of SwiftUI: A Cautionary Tale for iOS Developers
Let’s start!
Thinking in UIKit
Try opening your mind to the declarative approach of the framework. Check if things like environment keys work for you instead of ignoring them. Understand how SwiftUI views update (we will get to that later) and how to separate them properly. It may also make sense to evaluate if the architectural patterns you are familiar with still fit SwiftUI well, or if you may need to broaden your horizons there. I won’t go into that rabbit hole as part of this article, but maybe in the future, let’s see.
The guys behind objc.io have a great book to help you make this transition, called Thinking in SwiftUI.
For me, it always helps to see SwiftUI as an abstraction layer of how you want your view to look like, but unlike in UIKit, you will not directly access or control the actual view. You have to hand over that part to the framework and trust in it. I think this is also one of the most criticized parts of the framework. You are getting simplicity at the price of losing full control. But over time, working with SwiftUI, you will learn how to maneuver the framework. Though, you will very likely still find yourself searching for the simplest things way too long because SwiftUI will do something you weren’t aware of, or simply because it’s missing.
I personally have decided that I still prefer the simplicity of SwiftUI over writing a lot of UIKit code, but maybe I’m also just lying to myself. I would be very interested in seeing a statistic of how much time I’m saving using SwiftUI over UIKit vs. the time I’m wasting searching for things SwiftUI can’t do or did implicitly I wasn’t aware of.
Enforcing 100% SwiftUI
There are things that SwiftUI is good at, and there are things that it’s not. While today, I feel like if you have a very SwiftUI-heavy application, it will totally make sense to look for a SwiftUI solution for most parts, you have to be aware that sometimes it may still make sense to rely on good ol’ UIKit.
Apple themselves is doing it. Let’s take a look at the view hierarchy of the following code using the Xcode view debugger:
struct ContentView: View {
var body: some View {
List {
Text("Hello")
Text("World")
}
}
}
If you look closely, you will find UICollectionView
and UICollectionViewCell
related views and a lot of other UIKit-prefixed views.
Fun fact: Earlier versions of SwiftUI used UITableView
under the hood for List
, and in future versions, Apple may even drop UIKit under the hood for List
- who knows.
But the important thing I’m trying to say is: Like I said earlier, SwiftUI is an abstraction layer to make your views simpler. That comes at the cost of losing control over the view magic under the hood.
If you have very specific requirements for your UI component, e.g., you need to control the exact cell reuse in a list, or you need stellar scroll performance, as of the time I’m writing this article, it may still make sense to go back to UIKit.
Heck, it may even make sense to go back to a good ol’ drawRect:
implementation if necessary. But this always has to be decided case by case and with the skill set of the people you are working with in mind.
Situations where I had to go back to using UIKit in the past:
- Complex list layouts with good scroll performance, special requirements or very custom interaction handling
- Doing navigation before iOS 16 (thus before
NavigationStack
) - Handling a lot of text or custom text handling in general
While Apple claims “The greatest apps are built with Swift & SwiftUI”, I would always take that with a grain of salt. While I love using SwiftUI, to me, it’s still just another tool in the belt.
Mistaking a called body for a view re-render
Or, to be more specific, not knowing about the concept of view identity.
I’ve met a lot of fellow iOS developers throughout the last years, and interestingly, many times I mentioned view identity, hardly anybody heard of it before. There’s a great WWDC video that explains it: WWDC 2021 - Demystify SwiftUI. But I have to admit, I personally think this is one of SwiftUI’s largest pitfalls.
You might think that every time this computed property gets called by SwiftUI, it will re-render the view:
var body: some View {
...
}
As I mentioned earlier, SwiftUI is just an abstraction layer. The way SwiftUI handles view updates under the hood and the way it accesses the Swift abstraction layer are not directly connected.
It controls a whole separate view hierarchy of platform views that it will create and destroy on its own - and how you use SwiftUI will implicitly control this behavior.
Chris Eidhof has a great talk about this topic and talks about this in the book Thinking in SwiftUI I mentioned earlier.
Also, Paul Hudson has a great section about this topic in his book Pro SwiftUI.
So, when is a SwiftUI view re-rendered?
Essentially, a SwiftUI view updates when its structural or its explicit identity (defined using the id
modifier) changes.
Most of the time, it will be the structural identity, thus, properties that you defined as part of the view.
For that, it also doesn’t matter if those properties are annotated with @State
or not; this is also one of the things I’ve seen being misused a lot. You only need to apply @State
if you want to mutate the property. This is related to how SwiftUI manages view properties to survive the structs being destroyed, but this is a topic of its own.
Because more properties of a view mean more dependencies to the structural identity, in my experience, it makes sense to avoid large views with many states and move states into subviews if possible.
I might go more into detail about identity in another future article.
If you are uncertain when your view is re-rendered, you can always use the profiler or use Self._printChanges
.
Using the deprecated animation modifier
This topic may (thankfully) slowly become outdated, but I still want to mention it because I’ve seen it too many times - and you don’t notice the deprecation if you still support iOS 14.
If you animate your view using the deprecated animation modifier:
let items: [Item]
var body: some View {
List(items) { item in
Text(item.text)
}
.animation(.default)
}
Every change of the view’s identity will be animated. But you may only want to animate certain parts of the view, like the items in this example. I’ve seen the wildest UI glitches because of this code being hidden in component libraries.
To fix this, you can pass the variable that should actually be observed for changes, using another version of the modifier:
let items: [Item]
var body: some View {
List(items) { item in
Text(item.text)
}
.animation(.default, value: items)
}
Alternatively, you can use the explicit animation function when updating the property, or use the scoped animation block starting iOS 17.
Erasing the view type to AnyView
In SwiftUI, you often see the return type some View
. This is an opaque return type. SwiftUI views use a lot of generics. Essentially, the opaque return type allows the compiler to keep the information of the exact view type to optimize performance on re-render while not requiring you to always have to update the return type if you update the view hierarchy.
Let’s take a look at an example. We could also write the body of this view like this:
var body: Text { // <-- Text instead of some View
Text("Hello, world!")
}
But if we now add a `VStack, we get an error:
var body: Text { // ❌ Error: Cannot convert return expression of type 'VStack<Text>' to return type 'Text'
VStack {
Text("Hello, world!")
}
}
Of course, we could update the type to VStack<Text>
, but that would become very unhandy very quickly.
So let’s change the type back to some View
and print the type of body
in the init
:
struct ContentView: View {
init() {
print(type(of: body))
}
var body: some View {
VStack {
Text("Hello, world!")
}
}
}
You will have the following output:
VStack<Text>
If we now wrap the contents into AnyView
:
struct ContentView: View {
init() {
print(type(of: body))
}
var body: some View {
AnyView(
VStack {
Text("Hello, world!")
}
)
}
}
Even if we keep the some View
return type, the information will be lost:
AnyView
If you use AnyView
instead of some View
, you will erase all that information, and as a result, views may get re-rendered a lot more often, which may have performance impacts.
This does not always have to be the case, but in my opinion, it is a good practice to avoid AnyView
as much as possible.
There’s a great article by Martin Mitrevski that goes into detail regarding how much it can actually impact performance.
Only testing performance in DEBUG config
Xcode has made a crucial change in version 16, where it wraps every SwiftUI view in AnyView
in debug builds. This seems to be related to performance optimizations for previews but can cause performance issues in your app for the reasons mentioned before.
While you can keep this in mind and see when it will become a problem for you, you can also deactivate this setting using the compilation flag:
SWIFT_ENABLE_OPAQUE_TYPE_ERASURE=NO
You can read more about this topic in the Xcode 16.2 release notes, which also added experimental support for disabling the build setting while retaining preview usage.
Generally, this lesson taught me once more that you should always test your code in release build configuration as well.
Overusing conditional views
Maybe you are using an extension like this in your codebase:
@ViewBuilder
func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
Which can be used like this to conditionally add modifiers to a view:
var body: some View {
Text("Hello World!")
.if(condition) {
$0.redacted(reason: .placeholder)
}
}
While you may have gotten used to SwiftUI lacking fundamental things in the past, I think this is one of the things that are missing on purpose.
The problem, again, lies in structural identity and how SwiftUI handles views under the hood. Conditionally switching views like this results in SwiftUI not being able to recognize the Text
element in the example as the same element anymore. It will effectively create two different platform views for the two conditional cases. This can cause performance issues.
To make that more obvious, let me write down the code example without the extension:
var body: some View {
if condition {
Text("Hello World!")
.redacted(reason: .placeholder)
} else {
Text("Hello World!")
}
}
Another side effect would be that applying the animation modifier directly to the Text
element won’t work anymore as well:
var body: some View {
Text("Hello World!")
.if(condition) {
$0.redacted(reason: .placeholder)
}
.animation(.default, value: condition)
}
In this case, there’s a better way of implementing the same solution, using a ternary operator:
var body: some View {
Text("Hello World!")
.redacted(reason: condition ? .placeholder : [])
}
(This one’s two times non-obvious because the redacted modifier uses an OptionSet for the parameter - passing nil is not possible.)
Now, also the animation works properly:
var body: some View {
Text("Hello World!")
.redacted(reason: condition ? .placeholder : [])
.animation(.default, value: condition)
}
Doing work in the view initializer
As we learned before, calling the body
property is not directly connected to when the view will actually get re-rendered.
Thus, subviews that are created in the body and their initializers will get called very frequently - very likely more often than the platform views are re-rendered.
Following that, doing work in the view’s initializer, is not a good idea. In my experience, it’s a better practice to use onAppear(...)
or even task()
for that.
Misusing StateObject
and ObservedObject
It’s quite simple. The difference between StateObject
and ObservedObject
is the object’s lifetime.
- Use
StateObject
when you want to tie the lifetime of the object to the view that you created it in. - Use
ObservedObject
when the object’s lifetime is not handled by the current view and thus handled by an external object or view. This means the same object will be created as aStateObject
somewhere in the parent hierarchy.
Using this differently will result in unexpected view behavior / creation.
Conclusion
That’s it! I hope you could follow my article, and maybe you’ve learned something new today. Stay tuned for more articles in the future. I’m planning to release follow-ups on some of the mentioned topics, as well as Part 2 of pitfalls, once I’ve learned more in the future.
Cheers ✌️
- Tags:
- SwiftUI