Replies: 4 comments 6 replies
-
|
So to clarify:
Also, what is the reason you want to mix TCA and normal MVVM in the first place? |
Beta Was this translation helpful? Give feedback.
-
|
I don't know if the root problem I'm trying to solve here is "What do you do with reference state in TCA?" or "How do you deal with Shared acting like a value type while actually being a reference type?" I realize that's kinda the selling point for both of these libraries. But it creates these edge case scenarios. |
Beta Was this translation helpful? Give feedback.
-
|
If you don't need to change which underlying foo you are pointing to while already showing the child, it's easy, just use the StateObject trick. Then your VM will only be created once, and neither VM nor Shared will be recreated. The only thing is that it won't notice when you switch foo on the go. For that to work, you add an The StateObject thing is often useful for situations when you want to pass in the initial state of a child vm, but at the same time want the child view to own its own vm. struct Foo: Equatable {
var value: Int = 0
}
@Reducer
struct ParentFeature {
@ObservableState
struct State {
@Shared var foos: [Foo]
var child: Int? = nil
init() {
_foos = Shared(value: [.init(), .init(), .init()])
}
}
enum Action {
case hideChildButtonTapped
case select0ButtonTapped
case select1ButtonTapped
case select2ButtonTapped
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .hideChildButtonTapped:
state.child = nil
return .none
case .select0ButtonTapped:
state.child = 0
return .none
case .select1ButtonTapped:
state.child = 1
return .none
case .select2ButtonTapped:
state.child = 2
return .none
}
}
}
}
struct ParentView: View {
let store: StoreOf<ParentFeature>
var body: some View {
VStack(spacing: 20) {
Text("Foo Value 0: \(store.foos[0].value)")
Text("Foo Value 1: \(store.foos[1].value)")
Text("Foo Value 2: \(store.foos[2].value)")
HStack {
Button("Show 0") {
store.send(.select0ButtonTapped)
}
Button("Show 1") {
store.send(.select1ButtonTapped)
}
Button("Show 2") {
store.send(.select2ButtonTapped)
}
}
Divider()
if let childIndex = store.child {
Button("Hide Child") {
store.send(.hideChildButtonTapped)
}
let childFoo = store.$foos[childIndex]
ChildView(foo: childFoo)
.id(childIndex)
}
}
}
}
import Combine
@Observable @MainActor
final class ChildViewModel: ObservableObject {
@ObservationIgnored
@Shared var foo: Foo
init(foo: Shared<Foo>) {
_foo = foo
print("ChildViewModel.init")
}
deinit {
print("ChildViewModel.deinit")
}
}
struct ChildView: View {
@StateObject var viewModel: ChildViewModel
init(foo: Shared<Foo>) {
// This viewModel will only be created once
self._viewModel = .init(wrappedValue: .init(foo: foo))
}
var body: some View {
VStack {
Stepper("Foo", value: $viewModel.foo.value)
LabeledContent("Foo Value", value: viewModel.foo.value, format: .number)
}
.padding()
}
}
#Preview {
ParentView(
store: Store(
initialState: .init(),
reducer: {
ParentFeature()
}
)
)
} |
Beta Was this translation helpful? Give feedback.
-
ObservableObject doesn't add anything to your compiled code as far as I know, you can see it as a marker protocol. But I agree that it's a bit of a shame to have to use it. Hard to see why you'd object to using StateObject though, it was made specifically for storing reference types while letting the View manage the life cycle, it's a perfect fit.
You explicitly create a new instance of the VM every time the underlying value changes. If the ViewModel has any other state in it, that will not work since it will be reset all the time: .onChange(of: foo, initial: true) { _, newValue in
print("onChange(of:) newValue: \(newValue)")
viewModel = .init(foo: newValue)
}Even if you have a philosophical aversion to StateObject, I don't see why you'd want to re-instantiate the VM all the time. To be honest I don't understand the reason for the wrapperview either. When it comes to your onChange and identifiable, it is triggered by the instance being unequal to its former value, so it's not related to the Identifiable protocol. You could add any property with any name and type, as long as they are different for the different foos. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
How are people presenting vanilla Observable view model features from TCA parent features? I know it's been strongly suggested to not hold reference type state like a view model in TCA state. But this then presents a challenge in managing the view model lifecycle, and you’re forced to create a wrapper view to hold the view model with
@Stateto then pass to the child view. This is all reasonable until you then need to also seed the view model with data, and there is well known documentation on why seeding@Statefrom the outside is challenging.But to add to the challenge, I’m trying to seed the view model with Shared state. Because of how Shared works with Equatable, two Shared types are equal if their values are equal. But, their backing reference might not be the same. So if you try to use
onChangelike suggested by Chris in the link I shared, you get some strange behavior. Here is some code to demonstrate this...Screen.Recording.2025-12-26.at.1.32.52.PM.mov
If "Swap Child" is tapped right after "Push Child" while the underlying values are equal, SwiftUI doesn't see it as a changed value, so it doesn't trigger
.onChange, and the child view model's Shared reference is still pointed to the original parent Shared value. So when you increment the stepper, it first modifies the parent value, which triggersonChangewhich then updates the view model with the correct Shared reference, and then everything starts working as it should.If I remove
onChangeand use eitherState(initialValue:)in the init oronAppearto init the view model, then tapping "Swap Child" just never updates the underlying reference, and it will always update the original Shared value, which is worse.Admittedly this example might be convoluted but any potential solution has some downside or scenarios where it doesn't work correctly. Am I missing some better approach for this that doesn't have tradeoffs?
Beta Was this translation helpful? Give feedback.
All reactions