[SwiftUI] Closure Is Evil

Kurt Lee
6 min readApr 2, 2024

--

SwiftUI has many implicit features and implementations, especially around performance and re-rendering. most of them are undocumented and need a fair bit of playground check and Stackoverflow and Apple developer community searching around. I’ve personally spent the last 18 months building very complex macOS applications — web browsers with that, I would like to share some of those “undocumented tips”

TL;DR

  1. In Swift, closure is not equitable, nor is reference comparable
  2. In SwiftUI, unless you manually implement View: Equatable to ignore,
    if the view contains any (1) not equatable (2) not reference comparable property, it’ll re-evaluate the body every time a new View initialized
  3. If you’re building a very simple, i-dont-care-about-performance app, this most likely not be a problem. especially for iOS — this is mostly fine.
  4. If you’re building an App that shows many views simultaneously (like a macOS app) or an App that requires very frequent data rerendering, this will 100% be a problem
  5. So either
    a) declare moral bankruptcy and don’t care about the performance and use closure as a view property
    b) make closure box wrapper — action.

Evidence

As I aforementioned, Apple’s developer documentation doesn’t really address this issue. As a matter of fact, its documentation for View’s equality is very unclear. quite literally, this is the only official document you can find about this subject — which doesn’t explain anything about what’s the “default behavior” of View’s equality. (You can find little more information from WWDC sessions like this)

Besides from actual re-rendering example which I’ll show later on,
Very telling (yet very annoying) evidence for this problem is Apple’s internal implementations. Every single closure-looking dynamic property Apple provides as Environment(\.) is not a closure.
it’s struct with callAsFunction() implementation. They even have a very consistent suffix for it, “Action”

@Environment(\.openURL) private var openURL

public struct OpenURLAction {
/// - Parameter url: The URL to open.
public func callAsFunction(_ url: URL)
}

You might think this is because you can’t put closure directly to `Environment()`. well, you can.


extension EnvironmentValues {
var openModal: (Modal) -> Void {
get {
self [EnvironmentKey_openModal.self]
}
set {
self [EnvironmentKey_openModal.self] = newValue
}
}

private struct EnvironmentKey_openModal: EnvironmentKey {
static var defaultValue: Modal.OpenModal = { _ in }
}
}

struct ParentView: View {
@State var isHover: Bool = false

var body: some View {
ChildView()
.environment(\.openModal, { modal in
print("Trying to open Modal!", modal)
})
.background { isHover ? Color.red : Color.blue }
.hover { self.isHover = $0 }
}
}

struct ChildView: View {
@Environment(\.openModal) var openModal
var body: some View {
Text("Open").onTapGesture {
openModal(Modal())
}
}
}

So what’s the issue? Why does Apple do it in a more complicated way?
The issue is: that there is no way to prevent the re-render of the child view, if though we know for a fact that it doesn’t require any rerender

in this example, if you hover over the ParentView

  1. isHover will trigger ParentView.body computation
  2. Which then triggers ChildView() initializer
  3. We think (and hope) ChildView.body computation doesn’t happen — because it only has “openModal” as a dependency, which we’re sending identical closure
  4. But nope! it’ll trigger ChildView.body rerender

And this is regardless of the uniqueness of the closure

struct ParentView: View {
@State var isHover: Bool = false
static let openModal: (Modal) -> Void = { modal in
print("Trying to open Modal!", modal)
}

var body: some View {
ChildView()
.environment(\.openModal, Self.openModal) // "Same" closure right?
.background { isHover ? Color.red : Color.blue }
.hover { self.isHover = $0 }
}
}

This will give you the same result. (re-rendering of ChildView.body)

ok, so why?

Default Equality Behavior Of SwiftUI View

To understand this, you need to understand how SwiftUI View determines to rerender or not.

struct ChildView: View {
let title: String
let service: Service
@Environment(\.locale) var locale

var body: some View {
Text(title)
}
}

class Service {}
  1. View checks equality of stored property, such as “title” / “service”
    (You can override this part with explicitly Equitable conformance)
  2. View checks equality of injected property, such as “locale”
  3. If the property is Equatable, it uses Equatable implementations to compare. so in this case, prevView.title == nextView.title
  4. If the property is not equatable but still AnyObject, it will do a reference compare
    in this case, prevView.service === nextView.service

Couple more things are going in the background — (for example, in certain cases SwiftUI doesn't do (4) properly) — but in a nutshell this is what it does

We’ve been using Environment() as an example, but the same thing applies to stored property.

struct CustomButton: View {  
let title: String
let onClick: () -> Void
var body: some View {
Text(title).onTapGesture {
self.onClick();
}
}
}

however, you try to avoid rerendering of this view with reusing closure or such — none of that will work.

Problem with Closure in Swift

  1. closure is a reference type
  2. but you can’t do == or ===

there was some brave developer who already went through the rabbit hole before me if you want to know more

Edit) For the people who don’t follow every link I'll attach it here:
When I say closure can’t do ===, I literally meant

let a: () -> Void = { _ }

a === a // This compiles but always gives you "FALSE".

Yes I know, it’s strange — check the attached link for more.

Implication

So this means if there is a SwiftUI View that receives closure, it’s really hard to avoid body computation of that view.

You might think you can get away with

struct CustomButton: View, Equatable {   
static func == (lhs: Self, rhs: Self) -> Bool {
// We don't care about onClick
lhs.title == rhs.title
}

let title: String
let onClick: () -> Void
var body: some View {
Text(title).onTapGesture {
self.onClick();
}
}
}

You can to certain extends — But then this becomes a problem when you “Do need to” re-render upon closure change

struct ParentView: View {
let value: String
var body: some View {
CustomButton(title: "Title", onClick: {
print("Value", value)
});
}
}

struct GrandparentView: View {
@State var value: String = ""
var body: some View {
ParentView(value: self.value)
.onClick { self.value += "a" }
}
}

In this example, you would expect clicking GrandparentView() will trigger rerender of both (1) ParentView (2) CustomButton, so that clicking CustomButton will print latest value — which it doesn’t

ParentView will re-render, but to SwiftUI, “New” CustomButton(title: “title”) is equal to the previous CustomButton(title: “title”). thus it ignores rerender, thus onClick will still refer “old” value.

Solution & Summary

So what’s the “Solution?”

A most reliable solution is to follow what Apple does — make a value wrapper (“Action”) instead of using bare-bone closure

struct ClickButtonAction: Equtable {
static func == (lhs: Self, rhs: Self) -> Bool {
// We don't care about onClick
lhs.id == rhs.id
}

let id: UUID = .init()
let action: () -> Void
}

This way, you can

  1. Avoid unwanted re-render
  2. Force re-render when you intended it by creating a new action

Obviously, defining a new Struct for every single closure use will be quite lengthy. (Even though this is what Apple does for every single Environment action…) We can take advantage of generic here

public struct EqutableAction<ActionArgument, 
ActionReturn,
ActionID: Equatable> : Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
}

let id: ActionID
let action: (ActionArgument) -> ActionReturn
}

struct SimpleButton: View {
let onClick: EqutableAction<Void, Void, UUID>
}

struct ComplexDropdownSelector: View {
let list: Array<Value>
let onSelect: EqutableAction<Value, Void, UUID>
}

Conclusion

SwiftUI in general is a very well-designed framework. but to make it “Simple”, Apple intentionally (I assume) omits advanced, performance-related topics from the documentation. You can find some of those on WWDC videos, but even that alone is not enough to see the whole picture.

Again, if you’re making a very simple SwiftUI application that doesn’t require so much attention to performance or response time, most likely this will not be a problem — since even if we let some of the custom views do unnecessary re-render, any leaf view that requires actual update NSView or UIView (such as Button or Text…) will be properly handled re-render inside.

However, beware about the rendering cycle and View’s equality in general — that’s the almost only way to make the app faster on the SwiftUI level

--

--

Kurt Lee

AWS Serverless Hero, Seoul. Love building beautiful product, Proudly working hard at startup. Serverless, Typescript, AWS, Microservices