After more than two years, SwiftUI has evolved to its current version 3.0. Both the capabilities of SwiftUI and the Swift language itself have seen significant improvements during this time. It’s time to refactor my state container code using Async/Await.
SwiftUI’s State Container
I first encountered the ‘Single source of truth’ programming philosophy in Wang Wei’s book SwiftUI and Combine Programming. In general, it aligns closely with Redux’s logic:
- Treat the App as a state machine, with the UI as a specific representation of the App’s state (State).
- State (a value type) is stored in a Store object. To facilitate injection in views, the Store must conform to the
ObservableObject
protocol, and the State is wrapped in a@Published
property, ensuring any changes to the State are promptly responded to. - Views cannot directly modify State; they can only indirectly change the State in the Store by sending Actions.
- The Reducer in the Store is responsible for processing received Actions and updating the State as required by the Actions.
Typically, State, Store, and Action are defined as follows:
struct AppState {
var name: String = ""
var age: Int = 10
}
enum AppAction {
case setName(name: String)
case setAge(age: Int)
}
final class Store: ObservableObject {
@Published private(set) var state: AppState
func dispatch(action: Action) {
reducer(action)
}
func reducer(action)
}
When Reducers handle Actions, they often face situations with side effects, such as:
- Needing to query data from the network and modify the State based on that data.
- Writing data to disk or a database after modifying the State.
We can’t control the execution time of these side effects (some take longer than others), and side effects might continue to change the State through Actions.
Modifications to the state (State) must be performed on the main thread, or the view will not refresh properly.
Our state container (Store) needs to be capable of handling the above situations.
Version 1.0
When writing Health Notes 1.0, I used the approach proposed in the book SwiftUI and Combine Programming.
Side effects are handled by returning a Command from the Reducer. Commands use asynchronous operations to return results to the Store through Combine.
struct LoginAppCommand: AppCommand {
//...
func execute(in store: Store) {
//...
.sink(
receiveCompletion: { complete in
if case .failure(let error) = complete {
store.dispatch(
.accountBehaviorDone(result: .failure(error))
)
}
},
receiveValue: { user in
store.dispatch(
.accountBehaviorDone(result: .success(user))
)
}
)
}
}
func reduce(
state: AppState,
action: AppAction
) -> (AppState, AppCommand?)
{
// ...
case .accountBehaviorDone(let result):
// 1
appState.settings.loginRequesting = false
switch result {
case .success(let user):
// 2
appState.settings.loginUser = user
case .failure(let error):
// 3
print("Error: \(error)")
}
}
return (appState, appCommand)
}
The following method ensures that State is only modified on the main thread:
func dispatch(_ action: AppAction) {
let result = reduce(state: appState, action: action)
if Thread.isMainThread {
state = result.0
} else {
DispatchQueue.main.async { [weak self] in
self?.state = result.0
}
}
if let command = result.1 {
command.execute(in: this)
}
}
The author himself states in the book that the above code is experimental in nature. Therefore, although it is fully capable of performing the Store’s tasks, it is still quite complex in terms of logic organization, especially in the handling of each Command.
Version 2.0
By reading and studying Majid’s article Redux-like state container in SwiftUI, in Health Notes 2.0, I refactored the Store code.
The major improvement in Majid’s implementation is the significant simplification of the complexity of side effect code by centralizing the management of the Publisher’s lifecycle in the Store. Additionally, it uses Combine’s thread scheduling to ensure that State is only modified on the main thread.
func dispatch(_ action: AppAction) {
let effect = reduce(&state, action, environment)
var didComplete = false
let uuid = UUID()
let cancellable = effect
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] _ in
didComplete = true
self?.effectCancellables[uuid] = nil
},
receiveValue: { [weak self] in self?.send($0) }
)
if !didComplete {
effectCancellables[uuid] = cancellable
}
}
Reducer:
private let reduce: Reducer<AppState, AppAction, AppEnvironment> = Reducer { state, action, environment in
switch action {
case .editMemo(let memo, let newMemoViewModel):
return environment.dataHandler.editMemo(memo: memo, newMemoViewModel: newMemoViewModel)
case .setSelection(let selection):
state.selection = selection
}
return Empty(completeImmediately: true)
.eraseToAnyPublisher()
}
Side effect code:
func editNote(note: Note, newNoteViewModel: NoteViewModel) -> AnyPublisher<AppAction, Never> {
_ = _updateNote(note, newNoteViewModel)
if !_coreDataSave() {
logDebug("Error updating Note")
}
return Just(AppAction.none).eraseToAnyPublisher()
}
Version 3.0
Both versions 1.0 and 2.0 adequately fulfilled our requirements for the state container.
Both versions heavily rely on Combine, using it for the lifecycle management of asynchronous code, and in version 2.0, thread scheduling was done through Combine’s .receive(on: DispatchQueue.main)
.
Fortunately, Combine excellently performed these tasks, which are not its primary strengths (lifecycle management, thread scheduling).
This year, Swift 5.5 introduced the long-awaited Async/Await feature. After gaining some understanding of the new feature, I had the idea to implement a new state container using Async/Await.
- Use @MainActor to ensure State is only modified on the main thread.
- Dispatch creates a fire-and-forget Task to manage the lifecycle of side effects.
- Similar to version 2.0, return
Task<AppAction, Error>
in the side effect methods to simplify the side effect code.
Specific implementation:
@MainActor
final class Store: ObservableObject {
@Published private(set) var state = AppState()
private let environment = Environment()
@discardableResult
func dispatch(_ action: AppAction) -> Task<Void, Never>? {
Task {
if let task = reducer(state: &state, action: action, environment: environment) {
do {
let action = try await task.value
dispatch(action)
} catch {
print(error)
}
}
}
}
}
Reducer:
extension Store {
func reducer(state: inout AppState, action: AppAction, environment: Environment) -> Task<AppAction, Error>? {
switch action {
case .empty:
break
case .setAge(let age):
state.age = age
return Task {
await environment.setAge(age: 100)
}
case .setName(let name):
state.name = name
return Task {
await environment.setName(name: name)
}
}
return nil
}
}
Side effects:
final class Environment {
func setAge(age: Int) async -> AppAction {
print("set age")
return .empty
}
func setName(name: String) async -> AppAction {
print("set Name")
await Task.sleep(2 * 1000000000)
return AppAction.setAge(age: Int.random(in: 0...100))
}
}
Since Store is declared as @MainActor, we must reference it in our code in one of the following two ways:
@main
struct NewReduxTest3AppApp: App {
@StateObject var store = Store()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(store)
}
}
}
Or
@main
@MainActor
struct NewReduxTest3AppApp: App {
let store = Store()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(store)
}
}
}
The new version of the code is not only more readable but also fully benefits from the safety and efficiency of thread scheduling brought by Swift 5.5.
Conclusion
This reconstruction of the state container has given me a deeper understanding of Swift’s Async/Await and its importance in modern programming.