What is the best practice to set up a long-live publisher into a reducer without duplication? #1549
-
The PrerequisiteI am working with a 3rd party API that provides a import Combine
class APIClient {
static let shared: APIClient = .init()
let subject: PassthroughSubject<Int, Never> = .init()
private var cancellables: Set<AnyCancellable> = []
private init() {
Timer.publish(every: 1, on: .main, in: .default)
.autoconnect()
.map { _ in Int.random(in: 0 ..< 999 )}
.sink(receiveValue: self.subject.send)
.store(in: &self.cancellables)
}
} Basically, the API I am working with is the import Dependencies
extension DependencyValues {
var apiClient: APIClient {
get { self[APIClientKey.self] }
set { self[APIClientKey.self] = newValue }
}
private enum APIClientKey: DependencyKey {
static var liveValue: APIClient = .shared
}
} Then I implement the import ComposableArchitecture
struct Demo: ReducerProtocol {
struct State: Equatable {
var output: Int
}
enum Action: Equatable {
case viewAppear
case update(output: Int)
}
@Dependency(\.apiClient) var apiClient
var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .viewAppear:
return self.apiClient.subject
.map(Action.update(output:))
.eraseToEffect()
case .update(let output):
state.output = output
return .none
}
}
}
} And finally, to composite the import SwiftUI
struct DemoView: View {
var store: StoreOf<Demo>
var body: some View {
WithViewStore(self.store) { viewStore in
Text("\(viewStore.state.output)")
.onAppear {
viewStore.send(.viewAppear)
}
}
}
}
struct DemoView_Previews: PreviewProvider {
static var previews: some View {
DemoView(
store: .init(
initialState: Demo.State(output: 0),
reducer: Demo()
)
)
}
} The effect of the Questions
struct Demo: ReducerProtocol {
struct State: Equatable {
var output: Int
}
enum Action: Equatable {
- case viewAppear
- case update(output: Int)
}
@Dependency(\.apiClient) var apiClient
var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
- switch action {
- case .viewAppear:
- return self.apiClient.subject
- .map(Action.update(output:))
- .eraseToEffect()
-
- case .update(let output):
- state.output = output
- return .none
- }
}
+ .onceAndForAll { state in
+ return self.apiClient.subject
+ .assign(to: \.output, on: state)
+ .eraseToEffect()
+ }
}
}
struct DemoView: View {
var store: StoreOf<Demo>
var body: some View {
WithViewStore(self.store) { viewStore in
Text("\(viewStore.state.output)")
- .onAppear {
- viewStore.send(.viewAppear)
- }
}
}
} In which the
Any help is appreciated, thanks in advance! |
Beta Was this translation helpful? Give feedback.
Replies: 2 comments 5 replies
-
Hey @ylorn, thanks for the very nice case presentation. There is one thing that are not quite right in your implementation. In
enum ApiObservationID {}
var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .viewAppear:
return self.apiClient.subject
.map(Action.update(output:))
.eraseToEffect()
.cancellable(id: ApiObservationID.self)
case .viewDisappear:
return .cancel(id: ApiObservationID.self)
… This can be problematic however if
struct Demo: ReducerProtocol {
struct State: Equatable {
var output: Int
}
enum Action: Equatable {
case task
case update(output: Int)
}
@Dependency(\.apiClient) var apiClient
var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .task:
return .run { send in
for await output in self.apiClient.subject.values {
await send(.update(output: output))
}
}
case .update(let output):
state.output = output
return .none
}
}
}
}
struct DemoView: View {
var store: StoreOf<Demo>
var body: some View {
WithViewStore(self.store) { viewStore in
Text("\(viewStore.state.output)")
.task {
await viewStore.send(.task).finish()
}
}
}
} Please note the It is probably fine to have a dozen of views subscribing to your dependency at the same time. Please let us know if you have a specific case where you have much more features or real-life performance issues, as the solution is then probably bespoke. I don't know how it exactly works, but the
This is not really the way TCA is expected to work: If
Using an ad-hoc Please let me know if this helps with your problem. |
Beta Was this translation helpful? Give feedback.
-
@tgrapperon Thanks a lot for the detailed explanation and solution walkthrough! It gives me a better understanding of TCA! I have several questions about your response though:
is it possible to have an option to override/skip the subscription if its identifier already exists? like enum ApiObservationID {}
var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .viewAppear:
return self.apiClient.subject
.map(Action.update(output:))
.eraseToEffect()
.cancellable(
id: ApiObservationID.self,
+ option: .skipOnExisting // or .overrideOnExisting
)
- case .viewDisappear:
- return .cancel(id: ApiObservationID.self) So we can avoid having multiple subscription for the same instance. Is this a viable way?
enum Action: Equatable {
- case task
- case update(output: Int)
}
@Dependency(\.apiClient) var apiClient
var body: some ReducerProtocol<State, Action> {
+
+ Task { state in
+ for await value in self.apiClient.api1.values {
+ state.output1 = value
+ }
+ }
+
+ Task { state in
+ for await value in self.apiClient.api2.values {
+ state.output2 = value
+ }
+ }
+
+ Task { state in
+ for await value in self.apiClient.api3.values {
+ state.output3 = value
+ }
+ }
+
+ ...
}
...
struct DemoView: View {
var store: StoreOf<Demo>
var body: some View {
- WithViewStore(self.store) { viewStore in
- Text("\(viewStore.state.output)")
- .task {
- await viewStore.send(.task).finish()
- }
- }
+ WithTaskViewStore(self.store) { taskViewStore in
+ Text("\(taskViewStore.state.output1)")
+ Text("\(taskViewStore.state.output2)")
+ Text("\(taskViewStore.state.output3)")
+ ...
+ }
}
}
Sorry, I was thinking about the I am going to give it a go with your suggestions and see where it leads me. |
Beta Was this translation helpful? Give feedback.
Hey @ylorn, thanks for the very nice case presentation. There is one thing that are not quite right in your implementation. In
.viewAppear
, you create a subscription to theapiClient
, but you're not holding any reference to it. If this view disappears, the subscription will still be active and will start to emit actions for this non-existingDemo
domain. This is considered as a programming error in TCA. Furthermore, if it appears again, it will indeed create a new subscription. There are two ways to fix this issue:enum
and use this type as an identifier itself. You can sometimes use the red…