Our TaskProperty repo project built a new SwiftUI.DynamicProperty for managing stateful business logic. This is presented as a compelling alternative to keeping stateful business logic defined directly in view components.
Let’s see another example for more practice. This time we will build one of the most requested features I hear from product engineers building on SwiftUI: we will clone the useMemo hook from ReactJS.
The TaskProperty repo project is a meant as an introduction to the ideas and concepts behind custom dynamic properties for SwiftUI. It is strongly recommended you read through TaskProperty before continuing. If any of the concepts introduced here look unclear or confusing please check out the References cited from TaskProperty for more resources that can help explain these ideas.
Note
This project was built and tested from Xcode 27 beta 4 and macOS 26.6.
Let’s begin with a very simple data model:
struct Item: Hashable, Identifiable {
let id: Int
}The only property we care about for now is id: a unique Int value to identify this model.
Let’s build a SwiftUI.List to display an Array of Item values. We also need to support selection. We don’t need to display all the values. We only need to display the values identified with an even number:
import SwiftUI
struct EvensView: View {
@State private var selection: Item.ID?
private let array: Array<Item>
private var evens: Array<Item> {
print("computing new output")
return self.array.filter {
$0.id % 2 == 0
}
}
init(array: Array<Item>) {
self.array = array
}
var body: some View {
List(
self.evens,
selection: self.$selection
) {
Text(
$0.id,
format: .number
)
}
}
}Let’s build a SwiftUI.App to launch this EvensView. We will also add a Button that will append a new Item value to our Array:
@main
struct MemoPropertyDemoApp: App {
@State var array = (0 ..< 10).map { Item(id: $0) }
var body: some Scene {
WindowGroup {
EvensView(array: self.array).toolbar {
Button(action: self.addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
}
private func addItem() {
let item = Item(id: self.array.count)
self.array.append(item)
}
}Let’s launch the app from macOS and see what happens. We display a List with five Item values: we selected the values with an even identifier. When we tap the + button we see a new Item displayed in our List. We also see computing new output displayed to our console: our List computed its new values when we appended a new Item. This makes sense: if we append a new Item to our Array we need to run our filter operation to determine what the new even values are.
Our List also supports selection. When we tap individual rows we see computing new output displayed to our console. We did not add any new Item values to our source of truth. What happened? Why are we computing our even values again?
Our EvensView currently computes its evens property every time the body is computed. We are not currently doing anything to cache or memoize those values. Because the filter operation on our Array is linear time this implies that selecting a row is also linear time. Selecting a row updates our State which computes our body property. Computing our body property computes our evens property.
Suppose this EvensView would display many Item values. We would then be performing a lot of work just to support row selection. That’s a bummer.
Let’s suppose that we do want to support many Item values and this linear time operation is a bottleneck we want to control for. We want to memoize our evens property: if the input to our operation has not changed we can return a cached output.
Here is an attempt to memoize our evens values built from a dynamic property wrapper:
@propertyWrapper struct MemoEvens: DynamicProperty {
@State private var storage = Storage()
private let array: Array<Item>
init(array: Array<Item>) {
self.array = array
}
func update() {
self.storage.update(array: self.array)
}
var wrappedValue: Array<Item> {
self.storage.wrappedValue
}
}
extension MemoEvens {
private final class Storage {
private var array: Array<Item>
private var output: Array<Item>?
init() {
self.array = []
self.output = nil
}
func update(array: Array<Item>) {
if self.array != array {
self.array = array
self.output = nil
}
}
var wrappedValue: Array<Item> {
if let output = self.output {
return output
}
print("computing new output")
let output = self.array.filter {
$0.id % 2 == 0
}
self.output = output
return output
}
}
}Let’s try to walk through this code together and watch how this works:
- Our
MemoEvensis constructed with anArrayofItemvalues. - We save a
Storagereference toState. We will say more about this later. - We adopt
DynamicProperty. This will callupdatebefore the viewbodyproperty is computed whereMemoEvensis being used. - We return an
ArrayofItemvalues as awrappedValuefor our property wrapper. We return thisArrayfrom ourStoragereference.
Let’s take a closer look at our Storage reference:
- We call
updatewith anArrayofItemvalues. If theArrayis not equal by value to the previousArrayofItemvalues, we save the input and set ouroutputvalue tonil. - Our
wrappedValueproperty returns ouroutputif it is notnil. If ouroutputisnilwe compute it again and cache it for later.
That’s all we need for now. Let’s see what happens when we update our EvensView to use this new dynamic property:
struct EvensView: View {
@State private var selection: Item.ID?
@MemoEvens var evens: Array<Item>
init(array: Array<Item>) {
self._evens = MemoEvens(array: array)
}
var body: some View {
List(
self.evens,
selection: self.$selection
) {
Text(
$0.id,
format: .number
)
}
}
}Let’s try launching our app again from macOS. When we tap the + button we see computing new output printing to console. This makes sense: we appended a new Item value to our source of truth and this means we need to compute whether or not it was an even value. But when we start selecting rows we do not see computing new output printing again. We are still updating our State and computing a new body. Because the actual Array of Item values has not changed we can return the previously computed result.
This is great. So what could go wrong? Suppose while we were building this EvensView a different engineer on our product was building OddsView: a List component for displaying all the Item values with an odd identifier:
struct OddsView: View {
@State private var selection: Item.ID?
private let array: Array<Item>
private var odds: Array<Item> {
print("computing new output")
return self.array.filter {
$0.id % 2 != 0
}
}
init(array: Array<Item>) {
self.array = array
}
var body: some View {
List(
self.odds,
selection: self.$selection
) {
Text(
$0.id,
format: .number
)
}
}
}This engineer is now running into the exact same performance bottleneck we saw for our original EvensView implementation: every time the user selects a new row we compute another odds property with another linear time operation.
It would be great if we could optimize OddsView with the same strategy we used to optimize EvensView. We will memoize our odds values. If the input has not changed we can return the cached output:
@propertyWrapper struct MemoOdds: DynamicProperty {
@State private var storage = Storage()
private let array: Array<Item>
init(array: Array<Item>) {
self.array = array
}
func update() {
self.storage.update(array: self.array)
}
var wrappedValue: Array<Item> {
self.storage.wrappedValue
}
}
extension MemoOdds {
private final class Storage {
private var array: Array<Item>
private var output: Array<Item>?
init() {
self.array = []
self.output = nil
}
func update(array: Array<Item>) {
if self.array != array {
self.array = array
self.output = nil
}
}
var wrappedValue: Array<Item> {
if let output = self.output {
return output
}
print("computing new output")
let output = self.array.filter {
$0.id % 2 != 0
}
self.output = output
return output
}
}
}Our new MemoOdds dynamic property does help our performance bottleneck… but we now have a different problem. There is a lot of shared code between MemoEvens and MemoOdds. It’s literally just one line of code that changed: selecting odd values from our Array instead of even values.
What we want is some place to put the business logic to memoize an output value from a set of input values. We want this to be flexible and not tied to any one specific operation or algorithm. Let’s think through the basic requirements to set up and define the business logic to memoize an operation:
- We need to define a set of dependencies. These will be the values we pass to the operation to compute our
output. Every dependency also needs a corresponding operation we use to determine whether or not two dependency values have changed. When two dependency values have changed that implies we should compute a newoutputvalue. - We need to define an operation to compute our
output. We will pass our dependencies as an input.
Let’s define a new dynamic property named MemoProperty:
struct DependencySelector<Dependency> {
let dependency: Dependency
let didChange: (Dependency, Dependency) -> Bool
init(
dependency: Dependency,
didChange: @escaping (Dependency, Dependency) -> Bool
) {
self.dependency = dependency
self.didChange = didChange
}
}
struct OutputSelector<each Dependency, Output> {
let output: (repeat each Dependency) -> Output
init(output: @escaping (repeat each Dependency) -> Output) {
self.output = output
}
}
@propertyWrapper struct MemoProperty<each Dependency, Output> {
private var _storage = State(initialValue: Storage())
private var storage: Storage {
self._storage.wrappedValue
}
private var dependencySelector: (repeat DependencySelector<each Dependency>)
private var outputSelector: OutputSelector<repeat each Dependency, Output>
init(
dependencySelector: repeat DependencySelector<each Dependency>,
outputSelector: OutputSelector<repeat each Dependency, Output>
) {
self.dependencySelector = (repeat each dependencySelector)
self.outputSelector = outputSelector
}
var wrappedValue: Output {
self.storage.wrappedValue
}
}Let’s look through what is happening:
- We construct a
MemoPropertyvalue with a parameter pack ofDependencySelectorvalues and anOutputSelectorvalue. EveryDependencySelectorpairs aDependencyvalue with adidChangeclosure. ReturningtruefromdidChangewill be our signal toMemoPropertythat a newoutputshould be computed. TheOutputSelectordefines a closure that accepts eachDependencyas an input value and returns a newOutputvalue. - We save a
Storagereference toState. We will say more aboutStoragelater. As of this writing there seems to be an issue pairing variadic types with the newStatemacro from Xcode 27.1 We can work around this by using the “classic”Statedirectly. - We return the
wrappedValuefrom ourStoragereference as thewrappedValueof our property wrapper.
Our MemoProperty will also adopt DynamicProperty:
extension MemoProperty: DynamicProperty {
public func update() {
self.storage.update(
dependencySelector: repeat each self.dependencySelector,
outputSelector: self.outputSelector
)
}
}The SwiftUI infra will call update for us before our body property is computed. We have this opportunity to pass the new state to our Storage reference.
If our view component is constructed with new dependency values we will construct a MemoProperty with new dependency values. But we might want to pass new dependency values to MemoProperty directly: we might be composing MemoProperty with other dynamic properties. If we update our view component body without constructing a new view component from scratch we still need a way to pass the latest state to our MemoProperty value. Here is another method we can use to update our Storage reference:
extension MemoProperty {
public mutating func update(
dependencySelector: repeat DependencySelector<each Dependency>,
outputSelector: OutputSelector<repeat each Dependency, Output>
) {
self.dependencySelector = (repeat each dependencySelector)
self.outputSelector = outputSelector
self.update()
}
}Let’s look closer at our Storage reference:
extension MemoProperty {
fileprivate final class Storage {
private var dependency: (repeat each Dependency)?
private var output: Output?
var wrappedValue: Output {
guard let output = self.output else { fatalError("missing output") }
return output
}
}
}This is pretty simple so far. We define two private stored properties. We save our Dependency values and the Output value from the last time these were computed. Our wrappedValue unwraps the Output value. We crash if this is nil but we will see later why we can expect this not to crash during a SwiftUI lifecycle.
We needed a method to pass our latest state through from our MemoProperty:
extension MemoProperty.Storage {
func update(
dependencySelector: repeat DependencySelector<each Dependency>,
outputSelector: OutputSelector<repeat each Dependency, Output>
) {
if self.shouldUpdateOutput(
dependencySelector: repeat each dependencySelector
) {
self.updateOutput(
dependencySelector: repeat each dependencySelector,
outputSelector: outputSelector
)
}
}
}We need two more helper methods here. Let’s start with shouldUpdateOutput:
extension MemoProperty.Storage {
private func shouldUpdateOutput(
dependencySelector: repeat DependencySelector<each Dependency>
) -> Bool {
guard
let _ = self.output
else {
return true
}
if isEmpty(repeat each dependencySelector) {
return false
}
guard
let dependency = self.dependency
else {
return true
}
return didChange(
repeat (each dependencySelector).didChange,
lhs: repeat each dependency,
rhs: repeat (each dependencySelector).dependency
)
}
}For a new set of DependencySelector values our shouldUpdateOutput method should return true to indicate we need to run our OutputSelector again. Let’s walk through and see how this works:
- We start by inspecting our current
outputvalue. If we have never computed anoutputbefore we must compute a new one: we returntrue. Because we will call this from theupdatemethod on our dynamic property we can be sure we have anoutputcomputed at the time our view componentbodyis computed. This is how we can expect ourwrappedValuenot to crash with anilvalue. - If the pack of
DependencySelectorvalues thisoutputdepends on is empty we do not need to compute a newoutput. - If the set of dependencies we cached the last time we computed our
outputisnilwe must compute a newoutput. - We run the
didChangeclosures and compare the previous set ofDependencyvalues against the new set ofDependencyvalues.
We are missing a couple of helper functions for performing operations over parameter packs of values:
fileprivate func isEmpty<each Element>(_ element: repeat each Element) -> Bool {
// https://forums.swift.org/t/how-to-pass-nil-as-an-optional-parameter-pack/73119/6
for _ in repeat (each element) {
return false
}
return true
}
fileprivate func didChange<each Element>(
_ didChange: repeat @escaping (each Element, each Element) -> Bool,
lhs: repeat each Element,
rhs: repeat each Element
) -> Bool {
// https://github.com/swiftlang/swift-evolution/blob/main/proposals/0408-pack-iteration.md
for (_didChange, _lhs, _rhs) in repeat (each didChange, each lhs, each rhs) {
if _didChange(_lhs, _rhs) { return true }
}
return false
}Our isEmpty function is a simple option to quickly return a true value when this parameter pack is “empty”: no values were defined. Our didChange function calls a set of closures over two sets of Element values. If one of those pairs of values returns true from its didChange we return true to indicate that the two sets themselves have changed.
We are missing one more helper method on Storage:
extension MemoProperty.Storage {
private func updateOutput(
dependencySelector: repeat DependencySelector<each Dependency>,
outputSelector: OutputSelector<repeat each Dependency, Output>
) {
self.dependency = (repeat (each dependencySelector).dependency)
self.output = outputSelector.output(repeat (each dependencySelector).dependency)
}
}Every time we compute a new Output value we also cache our Dependency values.
This is all we need for now to build MemoProperty. Let’s see what this actually looks like as a replacement for the business logic we wrote to memoize evens and odds:
@propertyWrapper struct MemoEvens: DynamicProperty {
@MemoProperty<Array<Item>, Array<Item>> var wrappedValue: Array<Item>
init(array: Array<Item>) {
self._wrappedValue = MemoProperty(
dependencySelector: DependencySelector(
dependency: array,
didChange: { $0 != $1 }
),
outputSelector: OutputSelector(
output: { array in
print("computing new output")
return array.filter {
$0.id % 2 == 0
}
}
)
)
}
}
@propertyWrapper struct MemoOdds: DynamicProperty {
@MemoProperty<Array<Item>, Array<Item>> var wrappedValue: Array<Item>
init(array: Array<Item>) {
self._wrappedValue = MemoProperty(
dependencySelector: DependencySelector(
dependency: array,
didChange: { $0 != $1 }
),
outputSelector: OutputSelector(
output: { array in
print("computing new output")
return array.filter {
$0.id % 2 != 0
}
}
)
)
}
}This is much less code than we wrote last time. Let’s walk through MemoEvens and see how this is built:
- Our
wrappedValueofMemoEvensis now thewrappedValueof ourMemoProperty. OurMemoPropertyis defined to accept oneArrayofItemvalues as aDependencyand return a newArrayofItemvalues as anOutput. - We construct
MemoEvenswith anArrayofItemvalues. This is then passed to ourMemoPropertythrough ourDependencySelector. We also define adidChangeclosure that tests for value equality: when twoArrayinput values are not equal by value we must compute a new output value. - Our
OutputSelectoris then our previous business logic to compute even values: wefilteron ourArrayof input values to selectItemvalues with an even identifier.
Our MemoOdds only needs to change the business logic we define to OutputSelector. All the business logic to cache these values is now built in MemoProperty. When we build and run our app from macOS we see the same behavior as before: tapping + to add a new Item value computes our new output values and selecting a new row does not perform this work again.
One important limitation here to point out is that we assume the closure passed to OutputSelector is “pure”. The OutputSelector should only depend on the dependencies that were explicitly specified. If your OutputSelector depends on a value other than what was declared as a dependency this will lead to bugs: OutputSelector fails to compute a new output when the undeclared dependency has changed.
Our MemoEvens and MemoOdds dynamic properties are much smaller than before. But we are still writing a fair amount of code when really only one line of code here needs to be different: are we selecting evens or odds? Could we build another “higher order” dynamic property? Something that could help share even more business logic?
Let’s build MemoFilter. Here is a new dynamic property that is built to filter on an Array and memoize our results:
@propertyWrapper struct MemoFilter<Element>: DynamicProperty {
@MemoProperty<Array<Element>, Array<Element>> var wrappedValue: Array<Element>
init(
array: Array<Element>,
didChange: @escaping (Array<Element>, Array<Element>) -> Bool,
isIncluded: @escaping (Element) -> Bool
) {
self._wrappedValue = MemoProperty(
dependencySelector: DependencySelector(
dependency: array,
didChange: didChange
),
outputSelector: OutputSelector(
output: { array in
print("computing new output")
return array.filter(isIncluded)
}
)
)
}
}Our MemoFilter is currently generic over an Element which might or might not be Equatable. We can add a generic specialization here to indicate that when Element is Equatable we choose value equality as the default operation to determine if two Dependency values have changed:
extension MemoFilter where Element: Equatable {
init(
array: Array<Element>,
isIncluded: @escaping (Element) -> Bool
) {
self.init(
array: array,
didChange: { $0 != $1 },
isIncluded: isIncluded
)
}
}We can now make MemoEvens and MemoOdds even smaller:
@propertyWrapper struct MemoEvens: DynamicProperty {
@MemoFilter<Item> var wrappedValue: Array<Item>
init(array: Array<Item>) {
self._wrappedValue = MemoFilter(array: array) {
$0.id % 2 == 0
}
}
}
@propertyWrapper struct MemoOdds: DynamicProperty {
@MemoFilter<Item> var wrappedValue: Array<Item>
init(array: Array<Item>) {
self._wrappedValue = MemoFilter(array: array) {
$0.id % 2 != 0
}
}
}These two dynamic properties are now much more focused on only their specific algorithm. When we build and run our app from macOS we see the same behavior as before: tapping + to add a new Item value computes our new output values and selecting a new row does not perform this work again.
Our MemoFilter dynamic property defined a “default” didChange operation: we test our Dependency values for equality. When two Dependency values are not equal we must compute a new Output.
Let’s think through the performance implications of this decision. We already know that the performance of our Output algorithm is linear time: we visit every Element when we filter. But choosing value equality as the algorithm to test our Dependency values is also linear time: we have to visit every Element in both of these Array values.
An alternative would be the isTriviallyIdentical(to:) operation added from SE-0494. This operation is guaranteed to return in constant time to indicate two Array values must be equal. Please see Trivially-Identical-Sample for a detailed example of how this can be measured for performance improvements.
We currently memoize just one property: our Array of values we need to filter for. We also construct a new view component every time we have a new Array. A more advanced example where memoization can improve performance is from Trivially-Identical-Sample. That project needs to memoize over three values:
- An
ArrayofOrderdata model elements. - A
Stringof text we choose to search for and filter our data model elements. - An
ArrayofKeyPathComparator<Order>values to sort the results.
Here’s what the code looks like to memoize our results without using MemoProperty:
@propertyWrapper struct SortedOrders: DynamicProperty {
@State var sortOrder = [KeyPathComparator(\Order.creationDate, order: .forward)]
@State private var storage = Storage()
private var orders: [Order]
private var searchText: String
init(
orders: [Order],
searchText: String
) {
self.orders = orders
self.searchText = searchText
}
var wrappedValue: [Order] {
self.storage.wrappedValue
}
func update() {
let signposter = OSSignposter()
let state = signposter.beginInterval("SortedOrders.update")
defer {
signposter.endInterval("SortedOrders.update", state)
}
self.storage.update(
orders: self.orders,
searchText: self.searchText,
sortOrder: self.sortOrder
)
}
}
extension SortedOrders {
final class Storage {
private var output: [Order]? = nil
private var orders: [Order] = []
private var searchText: String = ""
private var sortOrder: [KeyPathComparator<Order>] = []
var wrappedValue: [Order] {
guard
let output = self.output
else {
fatalError("missing output")
}
return output
}
func update(
orders: [Order],
searchText: String,
sortOrder: [KeyPathComparator<Order>]
) {
if self.shouldUpdateOutput(
orders: orders,
searchText: searchText,
sortOrder: sortOrder
) {
self.orders = orders
self.searchText = searchText
self.sortOrder = sortOrder
self.updateOutput()
}
}
private func shouldUpdateOutput(
orders: [Order],
searchText: String,
sortOrder: [KeyPathComparator<Order>]
) -> Bool {
if self.output != nil,
self.orders.isTriviallyIdentical(to: orders),
self.searchText == searchText,
self.sortOrder == sortOrder {
return false
} else {
return true
}
}
private func updateOutput() {
if self.searchText.isEmpty {
self.output = self.orders.sorted(using: self.sortOrder)
} else {
self.output = self.orders.filter { order in
order.matches(searchText: self.searchText) || order.donuts.contains(where: { $0.matches(searchText: self.searchText) })
}.sorted(using: self.sortOrder)
}
}
}
}For this example we pass orders and searchText every time our view component is constructed. But our sortOrder is actually another local State: we are composing dynamic properties.
Let’s see what this looks like built on MemoProperty:
@propertyWrapper struct SortedOrders: DynamicProperty {
@State var sortOrder = [KeyPathComparator(\Order.creationDate, order: .forward)]
@MemoProperty<[Order], String, [KeyPathComparator<Order>], [Order]> var wrappedValue: [Order]
private var orders: [Order]
private var searchText: String
init(
orders: [Order],
searchText: String
) {
self.orders = orders
self.searchText = searchText
self._wrappedValue = MemoProperty(
dependencySelector: DependencySelector(
dependency: orders,
didChange: { $0.isTriviallyIdentical(to: $1) == false }
),
DependencySelector(
dependency: searchText,
didChange: { $0 != $1 }
),
DependencySelector(
dependency: [KeyPathComparator(\Order.creationDate, order: .forward)],
didChange: { $0 != $1 }
),
outputSelector: OutputSelector(
output: { orders, searchText, sortOrder in
if searchText.isEmpty {
return orders.sorted(using: sortOrder)
} else {
return orders.filter { order in
order.matches(searchText: searchText) || order.donuts.contains(where: { $0.matches(searchText: searchText) })
}.sorted(using: sortOrder)
}
}
)
)
}
mutating func update() {
let signposter = OSSignposter()
let state = signposter.beginInterval("SortedOrders.update")
defer {
signposter.endInterval("SortedOrders.update", state)
}
self._wrappedValue.update(
dependencySelector: DependencySelector(
dependency: self.orders,
didChange: { $0.isTriviallyIdentical(to: $1) == false }
),
DependencySelector(
dependency: self.searchText,
didChange: { $0 != $1 }
),
DependencySelector(
dependency: self.sortOrder,
didChange: { $0 != $1 }
),
outputSelector: OutputSelector(
output: { orders, searchText, sortOrder in
if searchText.isEmpty {
return orders.sorted(using: sortOrder)
} else {
return orders.filter { order in
order.matches(searchText: searchText) || order.donuts.contains(where: { $0.matches(searchText: searchText) })
}.sorted(using: sortOrder)
}
}
)
)
}
}We were able to move all the business logic to manage memoization down to MemoProperty. We still duplicate a fair amount of code because we need to construct MemoProperty with our DependencySelector and OutputSelector values. Let’s look at one way to try and clean this up:
@propertyWrapper struct SortedOrders: DynamicProperty {
@State var sortOrder = [KeyPathComparator(\Order.creationDate, order: .forward)]
@MemoProperty<[Order], String, [KeyPathComparator<Order>], [Order]> var wrappedValue: [Order]
private var orders: [Order]
private var searchText: String
init(
orders: [Order],
searchText: String
) {
self.orders = orders
self.searchText = searchText
}
mutating func update() {
let signposter = OSSignposter()
let state = signposter.beginInterval("SortedOrders.update")
defer {
signposter.endInterval("SortedOrders.update", state)
}
self._wrappedValue.update(
dependencySelector: DependencySelector(
dependency: self.orders,
didChange: { $0.isTriviallyIdentical(to: $1) == false }
),
DependencySelector(
dependency: self.searchText,
didChange: { $0 != $1 }
),
DependencySelector(
dependency: self.sortOrder,
didChange: { $0 != $1 }
),
outputSelector: OutputSelector(
output: { orders, searchText, sortOrder in
if searchText.isEmpty {
return orders.sorted(using: sortOrder)
} else {
return orders.filter { order in
order.matches(searchText: searchText) || order.donuts.contains(where: { $0.matches(searchText: searchText) })
}.sorted(using: sortOrder)
}
}
)
)
}
}We can start by removing the code to construct our MemoProperty from init. Here is how we can continue to compile:
@propertyWrapper struct MemoProperty<each Dependency, Output> {
...
private var dependencySelector: (repeat DependencySelector<each Dependency>)? = nil
private var outputSelector: OutputSelector<repeat each Dependency, Output>? = nil
init() {
self.dependencySelector = nil
self.outputSelector = nil
}
...
}We define a constructor that takes no arguments. We still need to make sure that dependencySelector and outputSelector are not nil before we update:
extension MemoProperty: DynamicProperty {
func update() {
guard
let dependencySelector = self.dependencySelector,
let outputSelector = self.outputSelector
else {
return
}
self.storage.update(
dependencySelector: repeat each dependencySelector,
outputSelector: outputSelector
)
}
}When we build and run our Food Truck app we see the same behavior as before: we can choose a new searchText and sortOrder at runtime and our Table updates with the correct Order values. We have already eliminated a lot of code: the business logic to memoize output from a set of dependencies needs no domain-specific knowledge from this product. We can build and test it from one place: our infra.
Copyright 2026 North Bronson Software
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.