-
Issue SummaryI am using the latest version (1.17.1) of TCA and have encountered an issue where BindingReducer does not properly observe nested state properties when using @ObservableState.
I separated ViewState to keep UI-related properties distinct from business logic in State, ensuring they remain testable while maintaining a clean state structure. Expected BehaviorPhotosPicker(
selection: $store.viewState.photoPickerItem, // Does not trigger Reducer
matching: .images,
photoLibrary: .shared()
) I expect the action .binding(.viewState.photoPickerItem) to be triggered inside the Reducer, allowing me to handle the new image selection. Actual Behavior
Additional Question
Code ExampleReducer @Reducer
public struct CreateProfileFeature {
@ObservableState
public struct State: Equatable {
var userName: String
var userImageData: Data?
var viewState: ViewState // Nested state
public init(
userName: String = "",
userImageData: Data? = nil,
viewState: ViewState = ViewState()
) {
self.userName = userName
self.userImageData = userImageData
self.viewState = viewState
}
}
public struct ViewState: Equatable {
var isPhotoPickerPresented: Bool
var photoPickerItem: PhotosPickerItem?
var testText: String
public init(
isPhotoPickerPresented: Bool = false,
photoPickerItem: PhotosPickerItem? = nil,
testText: String = ""
) {
self.isPhotoPickerPresented = isPhotoPickerPresented
self.photoPickerItem = photoPickerItem
self.testText = testText
}
}
public enum Action: Sendable, ViewAction {
case view(View)
case imagePicked(Data?)
@CasePathable
public enum View: Sendable, BindableAction {
case binding(BindingAction<State>)
case tapWriteButton
case tapImageInPicker(PhotosPickerItem?)
}
}
public var body: some ReducerOf<Self> {
BindingReducer(action: \.view)
Reduce { state, action in
switch action {
case .view(let action):
switch action {
case .binding(\.userName): // ✅ This works
print("UserName changed")
return .none
case .binding(\.viewState.photoPickerItem): // ❌ Never triggered
print("PhotoPickerItem changed")
return .none
case .binding(\.viewState.testText): // ❌ Never triggered
print("TestText changed")
return .none
case .tapWriteButton:
state.viewState.isPhotoPickerPresented = true
return .none
case .tapImageInPicker(let item):
return .run { [item] send in
if let item, let data = try? await item.loadTransferable(type: Data.self) {
await send(.imagePicked(data))
}
}
case .binding:
return .none
}
case .imagePicked(let data):
state.userImageData = data
return .none
}
}
}
} View(if needed) @ViewAction(for: CreateProfileFeature.self)
public struct CreateProfileView: View {
@Bindable public var store: StoreOf<CreateProfileFeature>
public init(store: StoreOf<CreateProfileFeature>) {
self.store = store
}
public var body: some View {
VStack(spacing: 20) {
TextField("Enter your name", text: $store.userName)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
TextField("Enter your TestText", text: $store.viewState.testText)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
HStack {
VStack {
if let imageData = store.userImageData,
let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.clipShape(Circle())
.frame(width: 100, height: 100)
} else {
Image(systemName: "person.circle.fill")
.resizable()
.foregroundColor(.gray)
.frame(width: 100, height: 100)
}
Text("$store.viewState.photoPickerItem")
.padding()
}
.overlay(alignment: .bottomTrailing) {
PhotosPicker(
selection: $store.viewState.photoPickerItem,
matching: .images,
photoLibrary: .shared()
) {
ZStack {
Circle()
.fill(Color.black)
.frame(width: 28, height: 28)
Image(systemName: "camera.fill")
.foregroundColor(.white)
.frame(width: 16, height: 16)
}
}
}
VStack {
if let imageData = store.userImageData,
let uiImage = UIImage(data: imageData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.clipShape(Circle())
.frame(width: 100, height: 100)
} else {
Image(systemName: "person.circle.fill")
.resizable()
.foregroundColor(.gray)
.frame(width: 100, height: 100)
}
Text(#"$store.viewState.photoPickerItem.sending(\.view.tapImageInPicker)"#)
.padding()
}
.overlay(alignment: .bottomTrailing) {
PhotosPicker(
selection: $store.viewState.photoPickerItem.sending(\.view.tapImageInPicker),
matching: .images,
photoLibrary: .shared()
) {
ZStack {
Circle()
.fill(Color.black)
.frame(width: 28, height: 28)
Image(systemName: "camera.fill")
.foregroundColor(.white)
.frame(width: 16, height: 16)
}
}
}
}
}
.padding()
}
} |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment 1 reply
-
Hi @FpRaArNkK, this is to be expected. If you were to use An alternative way to listen for changes is to use the Reduce { … }
.onChange(of: \.viewState.photoPickerItem) { … } This is mentioned in the docs. Another alternative would be to not bundle the state into a |
Beta Was this translation helpful? Give feedback.
Hi @FpRaArNkK, this is to be expected. If you were to use
_printChanges()
to see the actions being sent to your reducer you will find that it's abinding
action with the key path\.viewState
. The sub-key path of the actual field you are mutating is not known to the library at all. All that is known is thatviewState
was mutated.An alternative way to listen for changes is to use the
onChange
reducer operator:This is mentioned in the docs. Another alternative would be to not bundle the state into a
ViewState
struct and have it flattened directly in the feature state.