Filtering or transforming field on shared Global state when creating LocalState

Hi there, what would best practice be for transforming a field on global state when creating local state? E.g. this:

struct Ticket: Equatable {
    var expirationDate: Date
}

struct AppState: Equatable {
    var tickets: [Ticket]
    var other: OtherScreenState
}

extension AppState {
    var otherScreenFeatureState: OtherScreenFeatureState {
        get { .init(
            activeTickets: tickets.filter { $0.expirationDate > Date() },
            state: other) }
        set { ??? }
    }
}

struct OtherScreenState: Equatable {
    
}

struct OtherScreenFeatureState: Equatable {
    var activeTickets: [Ticket]
    var state: OtherScreenState
}

, where a field on localState is a filtered version of a field on the global state. It seems weird to have business logic there and also, how to put the 'scoped' field back into global state? Using Set and Hashable maybe? :thinking: Also this scoping might rely on dependencies, like Date.init in the example.

How to go about this scoping?

Hey there @jakobmygind, very good question, and we actually have a case study that covers this topic! :grinning:

If you check out the Todo example app you will see that it has filtering capabilities, and whenever you perform actions with a filter active (such as changing todo title or marking complete), it will correctly figure out which todo you are operating on.

It is important to know that it is not enough to merely refer to tickets via their index in the array, because as you see in your code example that index will change when a filter is applied. There is no way to figure out which ticket in the newValue corresponds to which ticket in self.

What you need instead is a unique identifier to associate with each ticket (which is how SwitUI pushes us to do collections anyway via the Identifiableprotocol). For simplicity we could do this:

struct Ticket: Equatable {
  let id = UUID()
  var expirationDate: Date
}

But in reality you are probably going to want a more robust way to create ids for your ticket items.

With that your computed property can be implemented, which by the way was the correct way to approach this. It may seem like you are putting "business" logic in the setter, but at the end of the day its simply model code that is invoked in the reducer (via pullback), and all of that code is exercised in tests (if you are writing tests :wink:) so it's a totally safe and reasonable to do.

So to implement the setter you need to update all the tickets in self that correspond to tickets in newValue handed to set:

set { 
  self.other = newValue.state
  for ticket in newValue.activeTickets {
    guard let index = self.tickets.firstIndex(where: { $0.id == ticket.id })
    self.tickets[index] = ticket
  }
}

However, this is of course messy (and inefficient), and that's why TCA ships with IdentifiedArray which makes it efficient to manipulate arrays of items that have unique identifiers. The Todo example app in the TCA repo demonstrates how to do this fully.

Hope that helps

Hi Brandon, thank you, yes, and good to know my approach makes sense! :sweat_smile: I guess the kind of filtering outlined in my example where filter has a dependency on Date.init either has to be done in the reducer or reach out into a global environment then in order to be testable, right? @mbrandonw

Ah yes, didn't notice that the first time I read. If you need access to a dependency in the environment (like Date), then you can't do that work purely in the setter, it must be done in a reducer proper. So in that case you definitely want to use IdentifiedArray, which means you can repeat the approach that we took in the Todos example app in the repo.

Got it, thx!

Terms of Service

Privacy Policy

Cookie Policy