Skip to content

Repository files navigation

Memo Property: Reusing More Stateful Business Logic for SwiftUI Views

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.

EvensView

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.

MemoEvens

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 MemoEvens is constructed with an Array of Item values.
  • We save a Storage reference to State. We will say more about this later.
  • We adopt DynamicProperty. This will call update before the view body property is computed where MemoEvens is being used.
  • We return an Array of Item values as a wrappedValue for our property wrapper. We return this Array from our Storage reference.

Let’s take a closer look at our Storage reference:

  • We call update with an Array of Item values. If the Array is not equal by value to the previous Array of Item values, we save the input and set our output value to nil.
  • Our wrappedValue property returns our output if it is not nil. If our output is nil we 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.

MemoOdds

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.

MemoProperty

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 new output value.
  • 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 MemoProperty value with a parameter pack of DependencySelector values and an OutputSelector value. Every DependencySelector pairs a Dependency value with a didChange closure. Returning true from didChange will be our signal to MemoProperty that a new output should be computed. The OutputSelector defines a closure that accepts each Dependency as an input value and returns a new Output value.
  • We save a Storage reference to State. We will say more about Storage later. As of this writing there seems to be an issue pairing variadic types with the new State macro from Xcode 27.1 We can work around this by using the “classic” State directly.
  • We return the wrappedValue from our Storage reference as the wrappedValue of 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 output value. If we have never computed an output before we must compute a new one: we return true. Because we will call this from the update method on our dynamic property we can be sure we have an output computed at the time our view component body is computed. This is how we can expect our wrappedValue not to crash with a nil value.
  • If the pack of DependencySelector values this output depends on is empty we do not need to compute a new output.
  • If the set of dependencies we cached the last time we computed our output is nil we must compute a new output.
  • We run the didChange closures and compare the previous set of Dependency values against the new set of Dependency values.

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 wrappedValue of MemoEvens is now the wrappedValue of our MemoProperty. Our MemoProperty is defined to accept one Array of Item values as a Dependency and return a new Array of Item values as an Output.
  • We construct MemoEvens with an Array of Item values. This is then passed to our MemoProperty through our DependencySelector. We also define a didChange closure that tests for value equality: when two Array input values are not equal by value we must compute a new output value.
  • Our OutputSelector is then our previous business logic to compute even values: we filter on our Array of input values to select Item values 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.

MemoFilter

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.

Performance

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.

Next Steps

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 Array of Order data model elements.
  • A String of text we choose to search for and filter our data model elements.
  • An Array of KeyPathComparator<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

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.

Footnotes

  1. https://developer.apple.com/forums/thread/838393

About

Reusing More Stateful Business Logic for SwiftUI Views

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages