Thanks for being a part of WWDC25!

How did we do? We’d love to know your thoughts on this year’s conference. Take the survey here

How to effectively use task(id:) when multiple properties are involved?

While adopting SwiftUI (and Swift Concurrency) into a macOS/AppKit application, I'm making extensive use of the .task(id:) view modifier.

In general, this is working better than expected however I'm curious if there are design patterns I can better leverage when the number of properties that need to be "monitored" grows.

Consider the following pseudo-view whereby I want to call updateFilters whenever one of three separate strings is changed.

struct FiltersView: View { 
  @State var argument1: String
  @State var argument2: String 
  @State var argument3: String  

  var body: some View { 
    TextField($argument1)
    TextField($argument2)
    TextField($argument3)

  }.task(id: argument1) {
    await updateFilters()

  }.task(id: argument2) {
    await updateFilters()

  }.task(id: argument3) { 
    await updateFilters()
  }
}

Is there a better way to handle this? The best I've come up with is to nest the properties inside struct. While that works, I now find myself creating these "dummy types" in a bunch of views whenever two or more properties need to trigger an update.

ex:

struct FiltersView: View { 
  struct Components: Equatable { 
    var argument1: String
    var argument2: String
    var argument3: String
  }

  @State var components: Components

  var body: some View { 
    // TextField's with bindings to $components...

  }.task(id: components) { 
    await updateFilters()
  }
}

Curious if there are any cleaner ways to accomplish this because this gets a bit annoying over a lot of views and gets cumbersome when some values are passed down to child views. It also adds an entire layer of indirection who's only purpose is to trigger task(id:).

Follow Up

Awhile back there was a discussion on Swift Forum that proposed creating an EquatableBox to solve this problem.

That solution leverages parameters packs. I've since adapted that and created a View modifier along the following lines. So far it seems to be working as expected, though I can't help but think I've overlooked something.

extension View {
  
  /// task(ids:)
  ///
  func task<each T: Equatable>(
    ids: repeat each T,
    priority: TaskPriority = .userInitiated,
    _ action: @escaping @Sendable () async -> Void
  ) -> some View {
    task(
      id: EquatablePack(repeat each ids),
      priority: priority,
      action
    )
  }
}

There isn’t one way to solve this problem, I suspect you could also leverage Observation and move your logic to an observable object and use the withObservationTracking(_:onChange:) function to track changes before enqueuing your task’s execution. That’s another option that might be worth exploring if you’re looking to keep your View as lightweight as possible.

How to effectively use task(id:) when multiple properties are involved?
 
 
Q