swift-composable-architecture
swift-composable-architecture copied to clipboard
View not updating with shared store and form
Description
I've hit a weird issue with the UI not updating. I am using a NavigationSplitView and the right and middle column share the same scoped store/state. A form/sheet is presented from the right column and on save the state in the parent reducer is properly updated. The UI in the right column is updated but the left is not.
I reproduced the issue in a smaller sample app (use iPad) and have slowly been removing parts of it to help isolate the issue. I believe I've got it down to the smallest possible. In the sample I have removed the NavigationSplitView and just placed the two views embedded within a parent to simplify it a bit.
Here is a video of the sample app. You can see on save the right view's "Sum" has been updated but the left column's has not. If you force an update via a local @State property the UI is correct. Also any other actions will trigger it to update as well.
https://github.com/user-attachments/assets/65cf5d18-3084-48b1-851d-a36931e41f9b
Some observations:
- Updating the number from the form save action actually works depending on how you update the state.
// Doesn't work ❌
state.numbers[id: form.id] = form
// Works weirdly enough ✅
state.numbers[id: form.id]?.number = form.number
Also if you add another property to the parent state other than the @Presents form and the numbers list and update it on the save it works.
- The form/sheet is required to make it happen. If you do a increment action without the form it all works correctly.
- If you strip things down further and copy the bodies of the
SumViewandNumberListViewinto theSplitViewit works correctly.
Admittedly I'm not sure if this is a bug in TCA or SwiftUI or I am doing something wrong since I am a bit rusty with TCA but everything seems correct as far as I can tell.
Checklist
- [X] I have determined whether this bug is also reproducible in a vanilla SwiftUI project.
- [X] If possible, I've reproduced the issue using the
mainbranch of this package. - [X] This issue hasn't been addressed in an existing GitHub issue or discussion.
Expected behavior
Both views should update
Actual behavior
Only the right column updates on the form saveFormTapped action.
Steps to reproduce
Run the sample app.
- Tap one of the numbers on the right
- Increment it
- Tap Save
- See right column is update and left is not
- Tap "Force Update" and see it now is correct
The Composable Architecture version information
1.11.2
Destination operating system
iOS 17
Xcode version information
15.4
Swift Compiler version information
swift-driver version: 1.90.11.1 Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: x86_64-apple-macosx14.0
Your issue might be that you need to conform Action to BindableAction. And use Binding Reducer.
enum Action: BindableAction {
case binding(BindingAction<State>)
}
var body: some Reducer<State, Action> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding:
return .none
}
}
}
}
Thanks for the suggestion and taking a look! In the actual app I am using BindableAction and BindingReducer. Just gave it a shot in the sample app for a sanity check and unfortunately it still doesn't work.
Actually ran into another issue with a view not updating. This example is a bit simpler. Put together a simple todo app. Kind of a weird setup having the list send the checkboxTapped action for the todo item but nonetheless it demostrates the issue. Hope this is helpful!
Just jumping in to say I also experienced this issue. Here is a demo app I used for testing.
https://github.com/user-attachments/assets/7bc1c567-5fde-43b9-9627-fbf2ede51506
In my setup, I have a ParentReducer (shown in white) that contains a ChildReducer (in blue). The ChildReducer holds an array of RowFeature reducers, each painted in red. The RowFeature has isSelected property, which is toggled within the ChildReducer using the following function:
public mutating func toggle(id: RowFeature.State.ID) {
if let selectedItem = items[id: id] {
items[id: id] = selectedItem.with(isSelected: !selectedItem.isSelected)
}
}
However, as shown in the video, while the ChildReducer detects this change, the ParentReducer does not.
And if I replace items[id: id] = selectedItem.with(isSelected: !selectedItem.isSelected) with items[id: id]?.isSelected = !selectedItem.isSelected the issue disappears.
I was able to fix it by replacing state.numbers[id: form.id] = form with:
state.numbers.remove(id: form.id)
state.numbers.append(form)
It seems like updating the element in the numbers array doesn't work but if you remove the old element and append the new one it does the trick:
https://github.com/user-attachments/assets/5068c3d5-7312-455c-840b-382c3516d6f0
met the same error, state change not correctly reflected to the view update
sometimes state change printed from console using _printChanges(), the state shows as changed, but attach a view for reflecting this state change, shows as not changed
no perception runtime warning found when testing
This seems related to my issue, so I posted a Q&A here: https://github.com/pointfreeco/swift-composable-architecture/discussions/3612. Missed this earlier—it’s older and seems stalled. Any updates?
Hi everyone,
I believe the behavior difference stems from how SwiftUI handles identity and state updates, particularly within collections like List or ForEach, or when using identity-driven APIs like .sheet(item:).
-
SwiftUI relies heavily on stable identity (the
IDyou provide) to efficiently manage views, track changes, and apply animations. For elements within a collection driving the UI, this identity is key. -
Mutation (
items[id: id]?.isSelected = ...):- This approach modifies a property (
isSelected) on the existing instance within theIdentifiedArray. - The specific instance in memory remains the same.
- SwiftUI recognizes this as an update within an element it already knows and tracks via its ID, making the change straightforward to process.
- This approach modifies a property (
-
Replacement (
items[id: id] = newItem):- This approach creates a new instance and replaces the old one entirely within the array.
- Even though the
IDis identical, the instance itself is different. - From SwiftUI's perspective, this can be interpreted as removing the old element and inserting a new one. While the state is correctly updated in the TCA Store, SwiftUI's view tracking mechanism can sometimes be disrupted by this replacement, especially if presentation logic (like
.sheet) or complex view state is tied to the original instance. It doesn't necessarily expect an instance with the same ID to be completely swapped out.
To explore this further, experimenting with .sheet(item: ...) is a great way to see how Vanilla SwiftUI binds presentation state to specific item instances based on their IDs.
P.S. I called elements as instance because they become reference type when they are involved in the tracking mechanism.