This library provides a convenient way for managing data that has to be loaded at runtime, for example from disk or from a HTTP API. It also has support for loading paginated data.
## The Basics
The core functionality of this library is provided by a property wrapper - @Loadable
-
and a high-order reducer that manages how and when that data should be loaded.
For example, lets assume we have some user data that needs to be fetched from an API - you
have an API client that you can use to load that data and you want the data to be loaded
when a view appears. The view will send an .onAppear
action from its onAppear
modifier.
First, you need to add a loadable property to your feature state - this property is marked as optional because all loadable data can be nil (because the data has not yet been loaded):
@Reducer
struct Feature {
struct State: Equatable {
@Loadable var profile: UserProfile?
}
}
To configure how this data is loaded you use the .loadable
higher-order reducer. First,
add a new action to your feature - this should wrap a LoadableAction<T>
which is generic
over the type of data being loaded:
@Reducer
struct Feature {
...
enum Action {
...
case profile(LoadableAction<UserProfile>)
}
}
Next, attach the .loadable
reducer - this requires a key-path to the LoadableState<T>
,
a case key path to the loadable action and an async throwing closure that performs the
actual load operation and returns the loaded data. The LoadableState<T>
value can be
accessed as the projected value of the @Loadable
property wrapper using the
dollar-sign prefix. The operation closure is passed a copy of the current state which
can be useful if you need to access that data as part of the load operation. You can
also access any dependencies you need in this closure to perform the load operation.
@Dependency(\.apiClient) var apiClient
var body: some ReducerOf<Self> {
Reduce { state, action in
...
}
.loadable(state: \.$profile, action: \.profile) { state in
try await apiClient.fetchUserProfile() // returns a decoded `UserProfile` value
}
}
In order to trigger the initial load, we need to put @Loadable
value into a
"ready to load" state - LoadableState
provides an API for controlling the load
state of a value. We can perform this mutation in the reducer when we receive the
onAppear
action:
@Dependency(\.apiClient) var apiClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
state.$profile.readyToLoad()
return .none
}
}
.loadable(state: \.$profile, action: \.profile) { state in
try await apiClient.fetchUserProfile() // returns a decoded `UserProfile` value
}
}
This is all that is needed to trigger the load - its important to note that the
load state must be mutated in a reducer that the .loadable
modifier is attached
to or it will not be able to detect the state transition.
Performing a load when a certain action is received is a fairly common use case
and so the .loadable
function provides a convenience for this by allowing you to
specify the list of actions that should trigger a load declaratively. The above
code can be rewritten as:
@Dependency(\.apiClient) var apiClient
var body: some ReducerOf<Self> {
Reduce { state, action in
return .none
}
.loadable(state: \.$profile, action: \.profile, performsLoadOn: \.onAppear) { state in
try await apiClient.fetchUserProfile() // returns a decoded `UserProfile` value
}
}
This library has been designed to be state-driven as much as possible. A loadable value
starts off in a .notLoaded(readyToLoad: false)
. The readyToLoad
parameter is used
to indicate to the loadable system that a value should be loaded. In the first example
above, calling $state.readyToLoad()
transitions to a .notLoaded(readyToLoad: true)
state - this is detected by the .loadable
reducer a load operation is performed.
Before the load operation begins, the state transitions to a .loading(T?)
state. The
optional T?
represents an existing loaded value. When loading for the first time this
will be nil
but the library supports reloading a value while keeping the current
value in memory. If the load operation is successful, it will transition to a
.loaded(T?, isStale: false)
state. Its important to note that the T?
is still optional
in this state, because it may be valid for a load operation to succeed but not return
a value. The isStale
parameter is used to indicate to the loadable system that the
data needs to be reloaded but not discarded (i.e. refresh).
Below is an overview of the API provided by LoadableState
:
$state.readyToLoad()
This will put the loadable value into a ready-to-load state, discarding any existing value, and trigger the data to be reloaded from scratch.
$state.unload()
This will put the loadable value back into a notLoaded
state, discarding any existing
value and will not trigger a reload.
$state.markAsStale()
If there is already an existing value, or a load is already in progress, this will mark
the value as stale, cancel any in-progress load operation and trigger a new load operation
without discarding the existing value. If the value is not loaded, this will behave the
same as calling readyToLoad()
.
$state.loading(withCurrentValue: true)
$state.failed()
$state.loaded(with: newValue)
These methods can be used to explicitly transition the loadable state into a loading, failed
or loaded state and are mainly intended for using @LoadableState
without the .loadable
reducer, allowing you to perform custom loading logic and manually manage the state
transitions.
It is possible to handle data reloading without having to manually perform a state transition.
The loadable reducer's' performsLoadOn:
parameter will automatically handle the case where
a value is already loaded and transition to a .loading(.some(existingValue))
state,
preserving the existing value. This is useful where you want the existing data to remain
visible in the UI, e.g. when handling pull to refresh:
// View
struct SomeView: View {
...
var body: some View {
if let profile = store.profile {
ProfileView(profile: profile)
.refreshable {
store.send(.pullToRefresh)
}
}
}
}
// Reducer
@Dependency(\.apiClient) var apiClient
var body: some ReducerOf<Self> {
Reduce { state, action in
return .none
}
.loadable(state: \.$profile, action: \.profile, performsLoadOn: [\.onAppear, \.pullToRefresh]) { state in
...
}
}
To manually trigger a refresh from your own reducer logic, call $state.markAsStale()
.
The loadable system also has full support for handling a range of paginated data types that you might typically encounter when working with a paginated REST API.
Thie functionality is built on top of two core protocols:
This protocol represents a single page of loaded values. It holds on to a collection of values for that page, a reference to the page they belong to and an optional reference to the next page, if there is one.
The library provides a single concrete implementation, PaginatedArraySlice
, which stores the
loaded values as an Array
and is generic over the page type. Three different page types are
provided by the library:
NumberedPage
- a page represented by a size (the number of records to load per page) and a numeric index representing the page number.OffsetPage
- a page represented by a limit (the number of records to load per page) and a numeric index representing the start index in the record collection.TimestampedPage
- a page represented by a size (the number of records to load per page) and an end date.
A paginated collection represents an aggregate collection of values constructed from each page of data as it is loaded. It can be initialized with an intial page of data and can be upserted with additional pages of data as they are loaded. Additional pages can be appended or prepended to the existing collection of data.
The library provides a single concrete implementation, IdentifiedPaginatedCollection
, which is
generic over its page type and any Identifiable
value. Values are stored in an
IdentifiedArray
and when upserting the collection with additional pages, any existing elements
with a matching ID are replaced with the value in the new page of data.
There are a number of overloads of .loadable
that are designed to be used with paginated data.
Firstly, you need to add a property to your state representing the loadable, paginated data. This
should be an optional collection type conforming to PaginatedCollection
. In most cases you can
just use the provided IdentifiedPaginatedCollection
type in combination with a page type that
best represents your API. In this example, we will use a simple numbered page type.
@Reducer
struct WidgetsFeature {
typealias WidgetCollection = IdentifiedPaginatedCollection<Widget, NumberedPage>
struct State: Equatable {
@Loadable var widgets: WidgetCollection?
}
enum Action {
case widgets(LoadableAction<WidgetCollection>)
}
}
Even though each load operation will only load a single page of data, the LoadableAction
is still
generic over the entire aggregate collection as every load operation will yield an updated collection.
Adding the .loadable
reducer is very similar to before, except for two main differences - the load
operation should load a single page of data and return a value that conforms to PaginatedData
.
You also need to specify the first page as this is will what be loaded in a .notLoaded
state. The
load operation closure will be passed a reference to the page being requested as well as the current
reducer state.
Tip
Paginated load operations are expected to return a value that conforms to PaginatedData
, such as
the built-in PaginatedArraySlice
. This will require you to decode the pagination data from your
API response into an appropriate page type in order to construct the paginated data. An example of
what this API response could look like might be:
{
"values": [...],
"pagination": [
"size": 10, // the number of records in this page
"count": 100, // the total number of records
"next_page": 2 // the index of the next page, if there is one
]
}
It is down to you to decode your API response into the types that the loadable
system requires -
knowing if there is a next page of data will allow the loadable system to automatically handle the
loading of the next page of data when requested.
@Reducer
struct WidgetsFeature {
...
private let pageOne = NumberedPage(number: 1, size: 50)
@Dependency(\.apiClient) var apiClient
var body: some ReducerOf<Self> {
Reduce { state, action in
...
}
.loadable(state: \.$widgets, action: \.widgets, firstPage: pageOne, performsLoadOn: \.onAppear) { page, _ in
let response = try apiClient.loadWidgets(page: page.number, count: page.size)
return PaginatedArraySlice(
values: response.widgets,
page: page, // you can just pass in the current page here
nextPage: response.pagination.nextPage.flatMap { nextPageNumber in
// Generally you want the next page to be the same size as the current.
return NumberedPage(page: nextPageNumber, size: page.size)
}
)
}
}
}
Whenever a reload is triggered, either by using the performsLoadOn:
parameter or by
calling markAsStale()
, the data will be loaded in one of three modes. The default
mode - upsertNext
- will check if there is a next page of data and if there is, it
will call the load operation closure with the next page and upsert the returned data
into the existing collection by appending the data to the end of the collection.
The upsertFirst
mode can be used to reload the first page of data and prepend it
to the exiting collection. This will cause any new values added to be prepended to
the beginning of the collection while any existing values will be updated with the
latest value. This mode is useful for a frequently updated collection of values that
are displayed with the newest values first.
The final mode, reload
, simply triggers a load of the first page of data and replaces
the entire collection with just that page of data - this is often the mode you want
to use when performing a pull to refresh operation on a list of paginated data.
The mode:
parameter takes a closure that receives the current state as a parameter
and returns a mode - this allows you to dynamically update the mode by storing it
in your feature state and changing it as needed. For example, if you want to perform
a reload on pull-to-refresh, you can handle this logic in your reducer:
@Reducer
struct WidgetsFeature {
typealias WidgetCollection = IdentifiedPaginatedCollection<Widget, NumberedPage>
struct State: Equatable {
@Loadable var widgets: WidgetCollection?
var loadingMode = LoadingMode.upsertNext
}
enum Action {
case widgets(LoadableAction<WidgetCollection>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .pullToRefresh:
state.loadingMode = .reload
state.markAsStale()
return .none
case .widgets(.loadRequestCompleted), .widgets(.loadRequestCancelled):
// Whenever a load request finishes we should reset the loading mode
state.loadingMode = .upsertNext
return .none
}
}
.loadable(
state: \.$widgets,
action: \.widgets,
firstPage: pageOne,
performsLoadOn: \.onAppear,
mode: \.loadingMode // equivalent to { $0.loadingMode }
) { page, _ in
...
}
}
}