m-housh/swift-tca-loadable

Multiple LoadableEnvironments?

Closed this issue · 3 comments

Thanks for this addition, one quick question.

I'm attempting to use this to manage persistence and requests to CoreData/CloudKit inside TCA.

As LoadableEnvironment supports loading one type (LoadedValue/load()), is the suggested approach to compose multiple LoadableEnvironments together per type? For example, fetching multiple Entities from CoreData (Users, Todos, Posts, etc).

Something like this:

import Foundation
import ComposableArchitecture
import TCALoadable
import Combine

struct AppEnvironment {
    var nameLoader: NameLoader
    var intLoader: IntLoader
}

struct NameLoader: LoadableEnvironment {
    
    typealias LoadedValue = String
    
    let failOnLoad: Bool
    
    let mainQueue: AnySchedulerOf<DispatchQueue>
    
    func load() -> Effect<String, Error> {
        
        guard !failOnLoad else {
                    return Fail(error: NameError.failed)
                        .delay(for: .seconds(1), scheduler: mainQueue)
                        .eraseToEffect()
                }
        
        return Just("chad")
            .setFailureType(to: Error.self)
            // Simulate a network call or loading from disk.
            .delay(for: .seconds(1), scheduler: mainQueue)
            .eraseToEffect()
    }
}

enum NameError: Error {
    case failed
}

struct IntLoader: LoadableEnvironment {
    
    typealias LoadedValue = Int
    
    let failOnLoad: Bool
    
    let mainQueue: AnySchedulerOf<DispatchQueue>
    
    func load() -> Effect<Int, Error> {
        
        guard !failOnLoad else {
                    return Fail(error: IntError.failed)
                        .delay(for: .seconds(1), scheduler: mainQueue)
                        .eraseToEffect()
                }
        
        return Just(100)
            .setFailureType(to: Error.self)
            // Simulate a network call or loading from disk.
            .delay(for: .seconds(1), scheduler: mainQueue)
            .eraseToEffect()
    }
    
    
}

enum IntError: Error {
    case failed
}

struct AppState: Equatable {
    /// A score loaded from the environment.
    var score: Loadable<Int> = .notRequested
    var name: Loadable<String> = .notRequested
}

enum AppAction: Equatable {
    case loadActions(LoadableActionsFor<Int>)
    case loadNameAction(LoadableActionsFor<String>)
}

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
    switch action {
    
    case .loadActions(_):
        return .none
    case .loadNameAction(_):
        return .none
    }
    
}
.loadable(
    state: \.score,
    action: /AppAction.loadActions,
    environment: { $0.intLoader }
)
.loadable(
    state: \.name,
    action: /AppAction.loadNameAction,
    environment: { $0.nameLoader }
)

struct TestLoadableView: View {
    let store: Store<AppState, AppAction>
    
    var body: some View {
        WithViewStore(store) { viewStore in
            VStack {
                LoadableView(
                    store: store.scope(
                        state: { $0.score },
                        action: { .loadActions($0) }
                    ),
                    autoLoad: true
                ) { loadedScore in
                    Text("Congratulations your score is: \(loadedScore)")
                }
                notRequestedView: { ProgressView() }
                isLoadingView: { _ in ProgressView() }
                errorView: { error in
                    VStack {
                        Text("Oops, something went wrong!")
                        Text(error.localizedDescription)
                            .font(.callout)
                        
                        Button(action: { viewStore.send(.loadActions(.load)) }) {
                            Text("Retry")
                        }
                    }
                }
                
                LoadableView(
                    store: store.scope(
                        state: { $0.name },
                        action: { .loadNameAction($0) }
                    ),
                    autoLoad: true
                ) { loadedName in
                    Text("Congratulations your name is: \(loadedName)")
                }
                notRequestedView: { ProgressView() }
                isLoadingView: { _ in ProgressView() }
                errorView: { error in
                    VStack {
                        Text("Oops, something went wrong!")
                        Text(error.localizedDescription)
                            .font(.callout)
                        
                        Button(action: { viewStore.send(.loadNameAction(.load)) }) {
                            Text("Retry")
                        }
                    }
                }
            }
        }
    }
}

struct Done_ComposableApp: App {
    let persistenceController = PersistenceController.shared
    
    var body: some Scene {
        WindowGroup {
            TestLoadableView(store: Store(
                initialState: AppState(),
                reducer: appReducer,
                environment: AppEnvironment(
                    nameLoader: NameLoader(failOnLoad: false, mainQueue: DispatchQueue.main.eraseToAnyScheduler()),
                    intLoader: IntLoader(failOnLoad: false, mainQueue: DispatchQueue.main.eraseToAnyScheduler())
                    
                )
                ))
            .environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
    }
}

I haven't used w/ core data yet. My use case for building was external API calls, where I get the entire type back. So loading would fail if the type wasn't decoded correctly. I would assume this approach would work on core data as well. So I guess it depends on how your entities are stored in core data.

struct UserScore: Equatable, Codable {
  let name: String
  let score: Int
}

struct UserScoreLoader: LoadableEnvironment {
  typealias LoadableValue = UserScore

  func load() -> Effect<UserScore, Error> {
    Just(UserScore(name: "User", score: 100))
  }
}

struct AppState: Equatable {
  var userScore: Loadable<UserScore> = .notRequested
}
...

Thanks, I can imagine crafting a CoreDataResponse struct w/ the needed data types I'm trying to pull.

Regarding CoreData, on further investigation it appears CoreData (at least w/ cloudkit) doesn't play very nicely with the composable architecture as it mutates independently of state and keeping it in sync is quite a chore.