Memoizing computed properties in SwiftUI

The SwiftUI view I was writing had gotten too slow. Any time it rendered, it spent a long time calculating one of its computed properties:

struct ContentView: View {
	var input: Int
	var unrelated: Int
	
	var expensive: Int {
		input * 2 // let’s pretend that multiplication is super slow
	}
	
	var body: some View {
		Text("Expensive: \(expensive)")
		Text("Unrelated: \(unrelated)")
	}
}

Sure, whenever input changed, that expensive multiplication had to be recalculated; after all, I was using expensive in the view body. But if only unrelated had a new value? The view really ought to skip the expensive computation, and reuse the previous result.

You’d think there was a built-in way to perform this sort of caching, this memoization; or a ready-to-use code snippet online; but I couldn’t find any. So here’s my take!

Using @Memoize

After copying the source (see below) into your codebase, using it only requires two changes:

  1. Add a @Memoize property, with the computed value’s type. This stores the cache.
  2. In the expensive getter, wrap the computation with a call to the newly-added property wrapper, passing all the input values as arguments.
struct ContentView: View {
	var input: Int
	var unrelated: Int
	
	@Memoize() private var expensiveCache: Int // 1. Add this
	
	var expensive: Int {
		_expensiveCache(input) { // 2. Wrap in that
			input * 2
		}
	}
	
	var body: some View {
		Text("Expensive: \(expensive)")
		Text("Unrelated: \(unrelated)")
	}
}

That’s it! Now, whenever the expensive property is accessed, it’ll first check the cache for an up-to-date value, skipping the closure execution if none of the inputs changed. If there are expensive calculations in some of your views, this can make quite the difference.

Do make sure you list all the input values (here, just the input variable, but you can pass as many as needed). Otherwise, the output value won’t update when it should. The only requirement for these inputs is Hashable conformance.

For types that can’t conform to Hashable, or hash too slowly, you can pass a single Equatable value instead. To enable this, you’ll first need to tell the property wrapper about the key type: @Memoize(key: SomeInput.self). The downside is increased memory usage, as the full input value will be stored as a cache key, instead of just a tiny hash.

The source

Download the ready-to-use Memoize.swift file, or read the code and implementation notes below if you’re curious.

Memoize.swift

@propertyWrapper
struct Memoize<Key: Equatable, Value>: DynamicProperty {
	var wrappedValue: Value {
		fatalError("To use @Memoize, call it as a function, passing the input value(s), and a closure to produce the value")
	}
	
	@State @Boxed private var cache: (key: Key, value: Value)?
	
	init(key keyType: Key.Type = Int.self) {}
	
	private func getMemoized(key: Key, produceValue: () throws -> Value) rethrows -> Value {
		if let cache, cache.key == key {
			return cache.value
		}
		
		let newValue = try produceValue()
		cache = (key: key, value: newValue)
		return newValue
	}
	
	func callAsFunction(_ key: Key, produceValue: () throws -> Value) rethrows -> Value {
		return try getMemoized(key: key, produceValue: produceValue)
	}
	
	func callAsFunction(
		_ key: any Hashable...,
		produceValue: () throws -> Value
	) rethrows -> Value where Key == Int {
		var keyHasher = Hasher()
		
		for keyPart in key {
			keyHasher.combine(keyPart)
		}
		
		return try getMemoized(key: keyHasher.finalize(), produceValue: produceValue)
	}
}

@propertyWrapper
fileprivate class Boxed<Wrapped> {
	var wrappedValue: Wrapped
	
	init(wrappedValue: Wrapped) {
		self.wrappedValue = wrappedValue
	}
}

Implementation notes

  • Yes, that’s a box in a state. The state is necessary because we want the cache to persist across renders—it’d be pointless otherwise. But, we need to update that state during view updates, which isn’t supported by SwiftUI. To get around this, we wrap the cache in a reference type—that’s the box—enabling us to update it at any time, without ever modifying the actual contents of the state itself.
  • Memoize is a DynamicProperty: this allows it to use SwiftUI property wrappers, such as @State.
  • The unusually-implemented wrappedValue is only there to help set the value type. It lets us type @Memoize() private var expensiveCache: Int instead of @Memoize(value: Int.type) private var expensiveCache. Arguably nicer.
  • Those empty parens in the @Memoize() call are necessary: it seems that without them, Swift doesn’t use our explicit init, and therefore skips the default key type.
  • To make calls to the memoization function more concise, we’re using callAsFunction, and an any Hashable... variadic parameter. Also, the wrapper takes care of hashing these input values, so you can have more than one input without needing to group them yourself, like you would for instance with onChange(of:).

Hopefully you find this bit of code useful. If you do try it, I’d love to hear your feedback!