Closures in SwiftUI Environment are killing your app’s performance

Closures in SwiftUI Environment are killing your app’s performance

SwiftUI undoubtedly has a lot of impressive capabilities and features, but unfortunately transparency is not one of them. Many components are designed and built as black boxes, with developers left attempting to piece together the full picture using the sparse information gathered from the documentation, WWDC videos, and reverse engineering. One of the most unfortunate consequences of that, in my opinion, is the lack of general intuition around possible causes for poor performance. In other words, if something I built in SwiftUI performs horribly, am I hitting one of the framework’s limitations, or am I holding it wrong?

In most cases, poor SwiftUI performance can boil down to accidentally causing unnecessary invalidations. This can be hard to debug, and even harder to develop good intuition about. One of the most easy to reach for tools is perhaps _printChanges(), which can be used to match your expectation of what should be happening in your hierarchy with what is actually happening at runtime.

Unnecessary invalidations can be caused by a variety of different things – and I may explore more of them in a separate post in the near future. For now, I’d like to zoom in on one particular cause that has a lot of misinformation and bad advice on it online: passing closures using the Environment.

The Problem

If you’ve ever looked for SwiftUI advice online or explored some open-source code out there, you’ve probably seen people passing actions through the Environment like so:

import SwiftUI

extension EnvironmentValues {
    @Entry var openSomething: () -> Void = { }
}

struct ContentView: View {
    @State private var isHovered: Bool = false
    
    var body: some View {
        CustomButton()
            .padding()
            .environment(\.openSomething, openSomething)
            .background {
                Color.primary
                    .opacity(isHovered ? 0.2 : 0.05)
            }
            .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
            .onHover {
                isHovered = $0
            }
    }
    
    private func openSomething() {
        print("Open clicked...")
    }
}

struct CustomButton: View {
    @Environment(\.openSomething) var openSomething
    
    var body: some View {
        let _ = Self._printChanges()
        Text("Click to Open")
            .onTapGesture {
                openSomething()
            }
    }
}

Seems like a much nicer alternative to passing closures directly or passing some ViewModel-esque object containing all the actions around, right? Sure, especially if you have a view nested deep inside the hierarchy responding to an action defined somewhere closer to the root. But let’s use the _printChanges I’ve added to CustomButton to match our expectation to the runtime behavior.

Here’s what I’d expect to happen:

  • Our print is triggered once during the initial evaluation of the view hierarchy, printing out something like CustomButton: @self changed..
  • Toggling isHovered, or any other hypothetical state of the parent view, does not invalidate CustomButton, and therefore does not trigger a print.
  • Tapping the button and triggering the action similarly does not cause an invalidation.

Essentially, I’m expecting that the custom button will be only evaluated once, or some very low constant number of times, where the number is not dependent on the number of state changes. This expectation can be important for performance, especially if you imagine a more complex or deeply nested view in the place of CustomButton. Let’s run the app and check what happens in reality:

  • ✅ There is an initial evaluation of the body.
  • ❌ However, during the initial presentation, the button’s body is churned through multiple times (6 in my case!), claiming my openSomething closure has changed:
CustomButton: @self, @identity, _openSomething changed.
CustomButton: _openSomething changed.
CustomButton: _openSomething changed.
CustomButton: _openSomething changed.
CustomButton: _openSomething changed.
CustomButton: _openSomething changed.
  • ❌ Once I hover the button, it invalidates its body and causes a print to happen again, claiming @self, _openSomething changed yet again. If I keep changing state, the button’s body is re-evaluated each time.

This just caused a lot more calls to body for our CustomButton than we expected! And, of course, these changes would’ve propagated further down the view hierarchy. That is, if our button component composed more views, those would be re-evaluated too, increasing the impact on performance. Similarly, if the view that contains the button component had more state that it managed, any changes to that state would also invalidate CustomButton. If we had more instances of CustomButton, those would be invalidated in a similar fashion.

It’s probably obvious why this is a huge performance issue waiting to happen and cause us much headache. If we decide to look into the issue a bit, we can use the hint from our _printChanges exploration to hypothesize that something must be causing SwiftUI to consider our Environment value to have changed, which is probably in turn causing all those redundant invalidations.

Closures do not support equality

In Swift, closures are reference types, but are neither equatable nor reference equatable. This is not some bug in Swift, but is actually very much by design. In the words of Chris Lattner on the Old Developer Forums (preserved thanks to this direct quote on StackOverflow):

This is a feature we intentionally do not want to support. There are a variety of things that will cause pointer equality of functions (in the swift type system sense, which includes several kinds of closures) to fail or change depending on optimization. If "===" were defined on functions, the compiler would not be allowed to merge identical method bodies, share thunks, and perform certain capture optimizations in closures. Further, equality of this sort would be extremely surprising in some generics contexts, where you can get reabstraction thunks that adjust the actual signature of a function to the one the function type expects.

The easiest way to confirm this behavior is declaring a closure and then attempting to reference-compare it to itself, like so:

let closureWithoutArguments: () -> Void = { }
print(closureWithoutArguments == closureWithoutArguments)
print(closureWithoutArguments === closureWithoutArguments)

Neither of the print statements will compile:

Cannot check reference equality of functions; operands here have types '() -> Void' and '() -> Void’.

Note that in previous versions of Swift, reference comparison (===) did compile, but the statement above would’ve returned false at runtime.

Actions in Environment: Exploring Alternatives

So, how do we pass an action down the Environment? Should we simply give up on the flexibility it offers and stick to other alternatives?

Bad: Closures in Environment

At this point, we can see why putting our closure in the Environment directly was such a bad idea. Since the internals of SwiftUI’s Environment implicitly track changes to values and invalidate the views as appropriate, we can hopefully see the problem now. Every time it checks our value, it’s technically different, so the framework must invalidate any views that depend on this value.

Better: Passing Closures Directly

We could revert to a simple action model that you would typically see on views like buttons.

struct ContentView: View {
    @State private var isHovered: Bool = false
    
    var body: some View {
        CustomButton(openSomething: openSomething)
            .padding()
            .background {
                Color.primary
                    .opacity(isHovered ? 0.2 : 0.05)
            }
            .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
            .onHover {
                isHovered = $0
            }
    }
    
    private func openSomething() {
        print("Open clicked...")
    }
}

struct CustomButton: View {
    let openSomething: () -> Void
    
    var body: some View {
        let _ = Self._printChanges()
        Text("Click to Open")
            .onTapGesture {
                openSomething()
            }
    }
}

The behavior here is much more predictable. We get:

  • A single CustomButton: @self changed. print on initial evaluation.
  • One CustomButton: @self changed. print every time isHovered changes.

The latter is still not ideal, of course. After all, my custom button does not depend on the hover state of its parent view. But in practice, if you’re dealing with a leaf view like a simple button, this can be a good tradeoff to make. The direct passing of the action is much more scoped and much more explicit than passing it through the environment, and the invalidation behavior is certainly more predictable. But, most importantly, it’s so simple!

Of course, one other alternative would be to encapsulate such actions inside some @Observable object that you can hold near the root pass through the Environment, then have your button call some function on it. But, of course, you are sacrificing some flexibility, and it doesn’t work for every use case.

Best: Use a Stable Object

If you’ve seen how SwiftUI’s built-in DismissAction works, you probably know what I’m about to suggest. If not, here’s a brief summary.

Case Study: DismissAction

SwiftUI includes a dismiss action in the Environment that you can use to dismiss things like sheets and popovers.

private struct SheetContents: View {
    @Environment(\.dismiss) private var dismiss


    var body: some View {
        Button("Done") {
            dismiss()
        }
    }
}

You use it like a closure, but it is not actually a closure. If you look at its definition, you will see that it’s a struct that implements a “magic” callAsFunction method.

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@MainActor public struct DismissAction {
    public func callAsFunction()
}

callAsFunction is what allows the environment value to be used as if it were a closure or a function. You can read more about methods with special names in Swift on the documentation website.

Implementing Callable Handlers

Let’s do the same for our openSomething closure. The first step is to wrap our action in a callable type and implement callAsFunction:

struct CallableHandler<Input> {
    typealias Handler = (Input) -> Void
    private let handler: Handler
    
    init(_ handler: @escaping Handler) {
        self.handler = handler
    }
    
    func callAsFunction(_ input: Input) {
        handler(input)
    }
}

extension EnvironmentValues {
    @Entry var openSomething: CallableHandler<Void>?
}

Now, let’s use it for our button. I may be tempted to do this in a fairly naive way, initializing the handler “on the fly.”

struct ContentView: View {
    @State private var isHovered: Bool = false
    
    var body: some View {
        CustomButton()
            .environment(\.openSomething, CallableHandler {
                print("Open clicked...")
            })
            .onHover { isHovered = $0 }
    }
}

struct CustomButton: View {
    @Environment(\.openSomething) var openSomething
    
    var body: some View {
        let _ = Self._printChanges()
        Text("Click to Open")
            .onTapGesture {
                openSomething?(())
            }
    }
}

❌ However, this would still cause hovering the parent view to invalidate the button – the same behavior as when passing a closure to it directly. The reason for that is because every time the parent view’s body is evaluated, we will put a new handler in the Environment, which will cause SwiftUI to attempt to diff it with the old one. In this case, SwiftUI will consider the new value to be different.

⚠️ We could, of course, work around this by implementing an explicit Equatable conformance for our CallableHandler. However, a more important piece of intuition about diffing that we can extract from this is:

  • Writing newly initialized structs and newly allocated objects to the environment will incur diffing costs, and
  • In some cases, the outcome of the diffing may be unexpected.

✅ Given that, a more general and flexible solution would involve making the handler stable and passing it down, like so:

private struct HandlerModifier: ViewModifier {
    @State var handler: CallableHandler<Void>
    
    init(handler: @escaping CallableHandler<Void>.Handler) {
        _handler = .init(wrappedValue: CallableHandler(handler))
    }
    
    func body(content: Content) -> some View {
        content
            .environment(\.openSomething, handler)
    }
}

extension View {
    func openSomethingHandler(_ handler: @escaping () -> Void) -> some View {
        self
            .modifier(HandlerModifier(handler: handler))
    }
}

We can then use the new modifier in our content view:

CustomButton()
    .openSomethingHandler {
        print("Open clicked...")
    }

If we check the behavior now, we will notice that changing the state of the parent view does not cause a body reevaluation of our custom button, which is precisely the effect we desired.

Generalizing

The solution can, of course, be generalized to allow you to pass actions with a variable number of parameters, return types, and environment keys.

For instance, I can make my CallableHandler use parameter packs for its inputs. Further, I can generalize the modifier to be useful for any action.

struct CallableHandler<each Input, Output> {
    typealias Action = ((repeat each Input)) -> Output
    private let action: Action
    
    init(_ action: @escaping Action) {
        self.action = action
    }
    
    func callAsFunction(_ update: repeat each Input) -> Output {
        action((repeat each update))
    }
}

private struct HandlerModifier<each Input, Output>: ViewModifier {
    typealias Key = WritableKeyPath<EnvironmentValues, Handler?>
    typealias Handler = CallableHandler<repeat each Input, Output>
    
    let key: Key
    @State private var handler: Handler
    
    init(key: Key, action: @escaping Handler.Action) {
        self.key = key
        self._handler = .init(wrappedValue: CallableHandler(action))
    }
    
    func body(content: Content) -> some View {
        content
            .environment(key, handler)
    }
}

extension View {
    func environmentHandler<each Input, Output>(
        _ key: WritableKeyPath<EnvironmentValues, CallableHandler<repeat each Input, Output>?>,
        action: @escaping CallableHandler<repeat each Input, Output>.Action
    ) -> some View {
        self
            .modifier(HandlerModifier(key: key, action: action))
    }
}

We could then easily declare new actions as entries, and reuse the same modifier to pass actions down.

extension EnvironmentValues {
    @Entry var openSomething: CallableHandler<Void>?
}

struct ContentView: View {
    @State private var isHovered: Bool = false
    
    var body: some View {
        CustomButton()
            .environmentHandler(\.openSomething) {
                print("Open clicked...")
            }
    }
}

Takeaways

It feels important to reiterate that the above solution is not a silver bullet or a hack that will somehow solve all your performance problems. This solves an extremely targeted problem, and before implementing a similar system in your codebase, it’s important to understand the problem space alongside the available alternatives. And, if a simpler solution works for your particular use case, it’s almost always better to start there!

  • If you’re dealing with extremely simple leaf views like Buttons, you’re likely better off simply passing the closure to it directly. It will make your code much clearer, simpler to reason about, and easier to iterate on. Of course, be aware of the tradeoffs you’ve made and reevaluate the decision if the button ever becomes more complex, if you start needing a lot of them, and / or if you start seeing a performance impact.
  • If you already keep most of your business logic on some object (e.g., an instance of an Observable type that is the State of the root view of your Scene), and you don’t need any view-specific state to perform the action, adding the action as an instance method on the type and calling it directly can be the lowest overhead solution.

Even if the flexibility of passing the actions through the Environment is indeed what your use case is calling for, it can be incredibly useful to align your expectations of the behavior with what is actually happening at runtime. This can help make SwiftUI internals seem a bit less opaque and develop a more robust intuition that will undoubtedly be of use in the future.