In the last article (UI vs API models) we talked briefly about the Repository layer.
Now it’s time to look closer into this layer, and how we can use Combine publishers to help drive the state of our views and our apps.
We will continue using the same Example app from the previous post.
Repository
public final class PokemonRepository: ObservableObject {
public var pokemonsPublisher = CurrentValueSubject<LoadingState<[Pokemon]>, Never>(.idle)
private let dependencies: Dependencies
private var inMemoryPokemons: [Pokemon] = []
public convenience init() {
self.init(dependencies: .default)
}
init(dependencies: Dependencies) {
self.dependencies = dependencies
}
}
public extension PokemonRepository {
@MainActor
func loadAllPokemons() async {
pokemonsPublisher.send(.loading)
do {
let apiPokemons = try await dependencies.getAllPokemons()
let pokemons = apiPokemons.map(\.uiModel)
inMemoryPokemons = pokemons
pokemonsPublisher.send(.success(inMemoryPokemons))
} catch {
pokemonsPublisher.send(.failure(error))
}
}
}
From this sample code, there are a few things to notice:
- We use a
CurrentValueSubject
to publish values to all the observers. - We use the Dependencies approach to talk to the backend (or inject mocks in previews/tests).
- We store the
inMemoryPokemons
in this class (we will use them later). - We provide a
public
initializer that uses the production service. - We provide an
internal
initializer to inject dependencies in the tests/previews (via@testable import
). - We provide a
public
method toload
all the pokemons. This method publishes a loading state, then perform some work via its dependency and then either publish a success or an error value (and it stores the array of pokemons inMemory).
Usage
From our SwiftUI view:
- We access the repository via
EnvironmentObject
:
@EnvironmentObject private var repository: PokemonRepository
- We can just use the
onReceive
modifier to observe changes in the publisher:
.onReceive(repository.pokemonsPublisher) { newState in
model.updateStates(with: newState)
}
Model
The model is a @State
property in the view. It is the one that drives the UI:
extension PokemonListView {
struct Model {
var pokemons: [Pokemon] = []
var isLoading: Bool = false
var error: Error? = nil
var isDisplayingDeletionErrorToast: Bool = false
}
}
extension PokemonListView.Model {
mutating func updateStates(with newState: LoadingState<[Pokemon]>) {
switch newState {
case .idle:
pokemons = []
isLoading = false
error = nil
case .loading:
isLoading = true
case .success(let t):
pokemons = t
isLoading = false
error = nil
case .failure(let e):
error = e
if e as? PokemonRepository.Errors == .deletionError {
isDisplayingDeletionErrorToast = true
}
isLoading = false
}
}
}
Optimistic approach
This is the basic usage, but what happens if we want for example, an optimistic approach for the delete
function.
@MainActor
/// Removes the pokemon with the given id.
/// - Parameters:
/// - id: the `id` of the pokemon.
/// - optimisticDelete: if `true` the repository will send the updated list of pokemons automatically,
/// while deleting with the API in the background. If the API call fails, the repository will re-publish the entire list.
func removePokemon(
id: String,
optimisticDelete: Bool = false
) async {
do {
var mutablePokemons = inMemoryPokemons
mutablePokemons.removeAll { pokemon in
pokemon.id == id
}
if optimisticDelete {
pokemonsPublisher.send(.success(mutablePokemons))
} else {
pokemonsPublisher.send(.loading)
}
// Delete the pokemon.
try await dependencies.deletePokemon(id)
// Refetch all the pokemons.
await loadAllPokemons()
} catch {
pokemonsPublisher.send(.failure(Errors.deletionError))
/// Re-Publish all the pokemons (without the deletion)
pokemonsPublisher.send(.success(inMemoryPokemons))
}
}
This is why we store the array of inMemoryPokemons
in the repository. It makes it super easy to have optimistic features when dealing with the API.
Then from the view, we can use the optimistic approach, and then react to the Errors.deletionError
if needed and display an alert.
Side note: We could use Toasts instead.
Wipe method
We should provide a way to wipe
the repository in case for example, the user logs out, or you want to provide a way to clear
all the data:
@MainActor
func wipe() {
pokemonsPublisher.send(.idle)
inMemoryPokemons = []
}
Testing
Both the Repository and the State Model should be covered with unit tests:
Testing the repository is quite simple, given we can inject whatever dependencies we want, so we can easily check the success and error states.
Testing the model state changes is also really simple, given we just need to call the updateStates
method with different states and then make some assertions.
Next iteration
The next natural step, would be to add an in-disk
cache for our repositories, but that would be part of a future article!
Related Articles
- UI vs API Models
- SwiftUI: Repository as a single source of truth
- Enhancing testability without protocols
- ViewState
- Toasts