Handling loadable data (case study)

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?

1 Like

I often add a isRefreshing to the loaded case. You can then refresh while displaying data.

Now, while I'd love a generic solution to loading things, I think this will be hard to pinpoint all use cases.
For example if you add caching your state must contain last update date and your action might need a force option to skip the cache.

One nice convenience would be the ability to zip Loadable to Loadable<(A, B)> so you can wait for multiple resource before displaying your view.

I agree it’s not going to be possible to come up with a generic solution that works for all scenarios. That isn’t really the goal here. I think what I’m aiming for is something that is a good starting point for somebody implementing this in their app and if that solution can be used as-is for a lot of general cases even better.

I’m not sure if this the right level of abstraction for dealing with caching concerns - this feels like behaviour that I would push down into the service layer.

You raise an important point though about refreshing - I think this is definitely something you’d want to model correctly because you probably want to show the current data while it is reloading.

I’ll have a play about with this later, my feeling is that this might be better as a separate case wrapping the current data than a flag on the loaded state.

We have a similar enum in place and added an error case.

public enum Loadable<Entity> {
  case none
  case loading(previous: Entity?)
  case error(previous: Entity?, error: Error)
  case some(Entity)
}

Its case names are derived from the Optional<T> type. As Error is not equatable, we're implementing the == operator by ourselves using reflection on the error object. The isRefreshing case is covered by the loading case as it holds the previous entity value. If there is a previous value, you're refreshing. If there is none, you're performing an initial load.

Yeah I think a case study for this kind of functionality would be great to have. As y'all have said, it would be hard to come up with a 100% generic version of this that works for everyone, but by showing how a basic version can be done it can serve as a blueprint for others to build their own. For example, another thing people may want to add to their loader is the idea of reachability so that they can react to being online/offline.

I think this case study also fits in nicely with some of our other "higher-order reducer" examples. This functionality can be split into 3 parts:

  • the generic domain of loading data, which includes the state machine of going through not loaded -> loading -> loaded/failure, etc.
  • A higher-order reducer that enhances any existing reducer with loading capabilities. This lets the core reducer focus only on the core logic for the feature, and never have to worry about where/how it obtains its data.
  • A generic view that coordinates which view to show based on the loading state.

We'd be happy to provide some feedback on a case study to get it into the main repo if you're interested in working on that.

2 Likes

There's a few versions of this idea floating around

https://package.elm-lang.org/packages/krisajenkins/remotedata/latest/


I'm fan of the type that Elm uses

type RemoteData e a
    = NotAsked
    | Loading
    | Failure e
    | Success a

so both the success and error types are left open (rather than constrain to Error)

I'd definitely be interested in working on fleshing this out into something that could go into the main repo.

I think there's some interesting discussion to be had around the level of abstraction certain things like caching, network reachability etc. should sit at. To me this feels like a lower level than the Loadable abstraction as it is very implementation specific - a disk load probably doesn't need caching, whereas a network load would need caching and reachability. I think I'd push this behaviour into whatever "loader" I'm supplying through the environment.

Whether or not an error case is useful - I think it can be, but depending on how you already handle errors in your app you might already have a more generalised error handling approach that you'd use.

I'd be interested to see how something like this could be wrapped up as a higher-order reducer - I would imagine that would probably require some common interface to be defined for the loader object that you supply through the environment.

I'm not sure I'd want to make this too network specific. While network loads are a very common case, I'd say it's just as common to want to lazily load some data off disk (or from a database) and only hold it in memory as long as you need it (and possibly get rid of it in response to low memory notifications if you can).

2 Likes

I know this is somewhat of an old topic, but I threw together this swift package for my common use case at least. I tried to make it as generic as possible to hopefully cover other use cases... If it's helpful.TCALoadable

Terms of Service

Privacy Policy

Cookie Policy