I thought it would be interesting to show an example of how you might handle loading data in a generic way in a composable architecture app and see if we can find ways of improving it collaboratively - maybe it could be donated as a case study to the core repo after some refinement if people think it is useful.
A common pattern is for your application to present some data that is lazily loaded, i.e. it is only loaded when a particular view over that data is requested. This could be anything...some JSON loaded from a file on disk, loaded from a database or some other persistent store, a network request etc. The actual mechanism for loading is not really important.
Given that our AppState
is supposed to be a model of our application state from the outset, it makes sense to represent this kind of data as a type. We could use T?
but that seems semantically weak - does a nil
value mean the data doesn't exist or does it mean it hasn't been loaded yet? Therefore, we can represent our data as:
enum Loadable<T: Equatable>: Equatable {
case notLoaded
case loading
case loaded(T)
}
Let's imagine we have some collection of items that we want to display - it could be quite a large dataset so we don't want to load it when the app loads - only when the particular view appears. We also want to display a "Loading..." view of some kind while the data loads. Our basic domain model looks like this:
struct Item: Identifiable, Equatable {
let id = UUID()
var label: String
}
struct AppState: Equatable {
var items: Loadable<[Item]> = .notLoaded
}
enum AppAction {
case viewAppeared
case itemsLoaded([Item])
}
struct AppEnvironment {
let itemLoader: () -> Effect<[Item], Never>
}
Our store is simple - we will simulate the asynchronous loading of data from some resource with a delayed effect that simply generates 10 items:
let store = Store<AppState, AppAction>(
initialState: AppState(items: .notLoaded),
reducer: appReducer, // see below
environment: AppEnvironment(
itemLoader: {
Effect<[Item], Never>(
value: (1...10).map {
Item(label: "Item \($0)")
}
)
.delay(for: 1, scheduler: DispatchQueue.main)
.eraseToEffect()
}
)
)
The reducer only has two actions to handle - we want to initiate the load when the view appears and update our state once the items are loaded:
let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
switch action {
case .viewAppeared:
return environment.itemLoader().map(AppAction.itemsLoaded)
case let .itemsLoaded(items):
state.items = .loaded(items)
return .none
}
}
We now have everything we need to display this data in a view. I've omitted ItemsView
- it just loops over the items and shows them in a List
.
struct MyView: View {
@ObservedObject var store: ViewStore<AppState, AppAction>
var body: some View {
Group {
(/Loadable<Item>.notLoaded).extract(from: loadable).map {
ProgressView("Loading")
}
(/Loadable< Item >.loaded).extract(from: loadable).map { loaded in
ItemsView(items: $0)
}
}
.onAppear {
store.send(.viewAppeared)
}
}
}
We can go one step further and extract a generic LoadingView
that conditionally displays either a loading or loaded view:
struct LoadableView<LoadedView: View, LoadingView: View, LoadedValue: Equatable>: View {
let loadable: Loadable<LoadedValue>
let loadingView: () -> LoadingView
let loadedView: (LoadedValue) -> LoadedView
init(
for loadable: Loadable<LoadedValue>,
@ViewBuilder loadingView: @escaping () -> LoadingView,
@ViewBuilder loadedView: @escaping (LoadedValue) -> LoadedView
) {
self.loadable = loadable
self.loadingView = loadingView
self.loadedView = loadedView
}
var body: some View {
(/Loadable<LoadedValue>.notLoaded).extract(from: loadable).map {
self.loadingView()
}
(/Loadable<LoadedValue>.loaded).extract(from: loadable).map { loaded in
self.loadedView(loaded)
}
}
}
Because it's likely we may want to show the same kind of loading view in multiple places in our app, we can wrap that up in a small helper function:
func ProgressLoadingView<LoadedValue: Equatable, LoadedView: View>(
for loadable: Loadable<LoadedValue>,
@ViewBuilder loadedView: @escaping (LoadedValue) -> LoadedView
) -> some View {
return LoadableView(
for: loadable,
loadingView: { ProgressView("Loading") },
loadedView: loadedView
)
}
Our original view is now just:
struct MyView: View {
@ObservedObject var store: ViewStore<AppState, AppAction>
var body: some View {
ProgressLoadingView(for: store.items) {
ItemsView(items: $0)
}
.onAppear {
store.send(.viewAppeared)
}
}
}
I thought about adding a loadingFailed(Error)
case but was unsure if this abstraction was useful. You might want to represent a failed load in this way (and you'd be able to extend the LoadingView
to handle errors in some generic fashion) but I imagine its common to already have kind of existing generic error handling mechanism that you might want to keep it decoupled from the Loadable
abstraction.
Is there anything else that should be considered that would be generally useful?