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.