Using State in effect calculation can lead to crashes with @ObservableState #2919
-
In one app I used the same struct (for convenience of writing less code) for a reducer state and expensive calculations off the main thread. Once the effect is done it's sending an action with the results, which I feed back into the current state. As the calculation is based on state I also added cancellation logic to cancel in flight effects and redo calculations, if the calculation input data changed. With ObservableState this doesn't work anymore. Making a copy of the state struct was previously fine. But with ObservableState it crashed my app as it triggered the observation on change off the main thread. I guess it' was never the best pattern to use the same struct, but back then it was the quickest way to implement it as my struct was quite big and needing those values to calculate statistics. The problem can be fixed by using a different struct or making a new struct instance copying only values, but not the observation ID so no on change is triggered as the calculation struct isn't observed. But I'm wondering if the TCA observation logic should runtime warn against being on a non main thread when triggering on change. Minimal sample. Sometimes you need to run it more times to get into the assertion. @Reducer
struct Feature {
@ObservableState
struct State {
var int = 42
}
enum Action {
case onAppear
case receiveUpdate(State)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
// Expensive calculation in effect
return .run { [state] send in
// Copying constant value type was previously safe to have all the state to do the calculation
// With @ObservableState it's not safe as the change will notify observers off the main thread
// and potentially crash.
var copy = state
var iter = 0
while iter < 10 {
// Update state with some calculation result
copy.int = Int.random(in: 0...10)
try await Task.sleep(nanoseconds: NSEC_PER_MSEC)
iter += 1
}
// Send results
await send(.receiveUpdate(copy))
}
case .receiveUpdate(let update):
state.int = update.int
return .none
}
}
}
}
struct ContentView: View {
let store: StoreOf<Feature> = .init(initialState: .init()) {
Feature()
}
var body: some View {
WithPerceptionTracking {
VStack {
let _ = assert(Thread.isMainThread)
Text(store.int.description)
}
.padding()
.onAppear {
store.send(.onAppear)
}
}
}
} |
Beta Was this translation helpful? Give feedback.
Replies: 2 comments 4 replies
-
Perhaps something like a |
Beta Was this translation helpful? Give feedback.
-
I think this behavior is understandable, and it is technically reproducible in vanilla SwiftUI, but because classes aren't trivially sendable it typically comes with warnings. For now it can be addressed in an app by:
We're open to other ideas, though. If anyone thinks that we can leverage concurrency and its diagnostics better, please let us know! |
Beta Was this translation helpful? Give feedback.
I think this behavior is understandable, and it is technically reproducible in vanilla SwiftUI, but because classes aren't trivially sendable it typically comes with warnings.
For now it can be addressed in an app by:
state
in an effect. Instead pluck out the fields that are needed.@MainActor
. Even if an expensive computation is performed in an effect, the local effect's context can usually be@MainActor
and not tie things up.We're open to other ideas, though. If anyone thinks that we can leverage concurrency and its diagnostics better, please let us know!