Implementing Redux with async/await introduced in Swift 5.5 has become very simple. From Xcode 13.2, Swift 5.5's new concurrency supports iOS 13. Therefore, the existing Redux package was newly implemented based on async/await.
Redux only support Swift Package Manager.
dependencies:[.package(url:"https://github.com/ReactComponentKit/Redux.git", from:"1.2.1"),]The figure above shows the flow of Redux. There's a lot of content, but it's actually very concise. The store handles most of the flow. All the developer has to do is define State and Store and define the functions that perform Action and Mutation. Additionally, middleware-like jobs can be defined so that you can do the necessary tasks before or after Mutation occurs.
State can be defined as below.
structCounter:State{varcount=0}Note that State should comply with Equatable.
When defining a store, a state is required. You can define the store as below.
structCounter:State{varcount=0}classCounterStore:Store<Counter>{init(){
super.init(state:Counter())}}The store provides the following methods.
- commit(mutation:, payload:)
- dispatch(action:, payload:) async
- dispatch(action:, payload:)
When creating a custom store, the method mainly used will be commit(mutation:, payload:). dispatch(action:, payload:) is very rarely used.
Mutation is defined as a store method. The Mutation method is a sync method.
// mutation
privatefunc increment(counter:inoutCounter, payload:Int){
counter.count += payload
}privatefunc decrement(counter:inoutCounter, payload:Int){
counter.count -= payload
}Action is also defined by the store's method. There is no need to create a separate custom data type for action anymore. Since Action is defined as a store method, there are very few cases where the store's dispatch method is actually used.
Action can be defined by dividing it into Sync Action or Async Action. Since the state change occurs in the commit, there is no need to define additional middleware for asynchronous processing. You can complete asynchronous processing in the async action and then commit the changes.
// actions
func incrementAction(payload:Int){self.commit(mutation: increment, payload: payload)}func decrementAction(payload:Int){self.commit(mutation: decrement, payload: payload)}func asyncIncrementAction(payload:Int)async{awaitTask.sleep(1*1_000_000_000)self.commit(mutation: increment, payload: payload)}func asyncDecrementAction(payload:Int)async{awaitTask.sleep(1*1_000_000_000)self.commit(mutation: decrement, payload: payload)}Also, You can use simplified commit method to define action or mutate state.
func asyncIncrementAction(payload:Int)async{awaitTask.sleep(1*1_000_000_000)self.commit{ mutableState in mutableState.count +=1}}Store's commit method is public so you can use it on the UI layer.
Button(action:{ store.counter.commit{ $0.count +=1}){Text(" + ").font(.title).bold()}or use store's action method.
Button(action:{ store.counter.incrementAction(payload:1)}){Text(" + ").font(.title).bold()}Define the properties to connect to View. The store does not publish the state. Therefore, in order to publish a specific property of the state, a value can be injected into the property in the computed step.
classCounterStore:Store<Counter>{init(){
super.init(state:Counter())}
// computed
@Publishedvarcount=0overridefunc computed(new:Counter, old:Counter){self.count = new.count
}...}The entire code of CounterStore defined so far is as follows.
import Foundation
import Redux
structCounter:State{varcount=0}classCounterStore:Store<Counter>{init(){
super.init(state:Counter())}
// computed
@Publishedvarcount=0overridefunc computed(new:Counter, old:Counter){self.count = new.count
}
// mutation
privatefunc increment(counter:inoutCounter, payload:Int){
counter.count += payload
}privatefunc decrement(counter:inoutCounter, payload:Int){
counter.count -= payload
}
// actions
func incrementAction(payload:Int){self.commit(mutation: increment, payload: payload)}func decrementAction(payload:Int){self.commit(mutation: decrement, payload: payload)}func asyncIncrementAction(payload:Int)async{awaitTask.sleep(1*1_000_000_000)self.commit(mutation: increment, payload: payload)}func asyncDecrementAction(payload:Int)async{awaitTask.sleep(1*1_000_000_000)self.commit(mutation: decrement, payload: payload)}}You can optionally add middlewares. Middleware is a collection of functions called before or after all Mutations.
You can optionally add Middleware. Middleware is a collection of sync functions called before and after all mutations are commited. For example, you can define middleware that print logs to debug state changes.
classWorksBeforeCommitStore:Store<ReduxState>{init(){
super.init(state:ReduxState())}overridefunc worksBeforeCommit()->[(ReduxState)->Void]{return[{(state)inprint(state.count)}]}}classWorksAfterCommitStore:Store<ReduxState>{init(){
super.init(state:ReduxState())}overridefunc worksAfterCommit()->[(ReduxState)->Void]{return[{(state)inprint(state.count)}]}}It is very easy to test the CounterStore defined above.
import XCTest
@testableimport Redux
finalclassCounterStoreTests:XCTestCase{privatevarstore:CounterStore!overridefunc setUp(){
super.setUp()
store =CounterStore()}overridefunc tearDown(){
super.tearDown()
store =nil}func testInitialState(){XCTAssertEqual(0, store.state.count)}func testIncrementAction(){
store.incrementAction(payload:1)XCTAssertEqual(1, store.state.count)
store.incrementAction(payload:10)XCTAssertEqual(11, store.state.count)}func testPublisherValue(){XCTAssertEqual(0, store.count)
store.incrementAction(payload:1)XCTAssertEqual(1, store.count)
store.incrementAction(payload:10)XCTAssertEqual(11, store.count)
store.decrementAction(payload:10)XCTAssertEqual(1, store.count)
store.decrementAction(payload:1)XCTAssertEqual(0, store.count)}func testAsyncIncrementAction()async{await store.asyncIncrementAction(payload:1)XCTAssertEqual(1, store.state.count)XCTAssertEqual(1, store.count)await store.asyncIncrementAction(payload:10)XCTAssertEqual(11, store.state.count)XCTAssertEqual(11, store.count)}func testAsyncDecrementAction()async{await store.asyncDecrementAction(payload:1)XCTAssertEqual(-1, store.state.count)XCTAssertEqual(-1, store.count)await store.asyncDecrementAction(payload:10)XCTAssertEqual(-11, store.state.count)XCTAssertEqual(-11, store.count)}}Let's define a store that uses API(https://jsonplaceholder.typicode.com).
import Foundation
import Redux
structUser:Equatable,Codable{letid:Intvarname:String}structUserState:State{varusers:[User]=[]}classUserStore:Store<UserState>{init(){
super.init(state:UserState())}
// mutations
privatefunc SET_USERS(userState:inoutUserState, payload:[User]){
userState.users = payload
}privatefunc SET_USER(userState:inoutUserState, payload:User){letindex= userState.users.firstIndex{ it in
it.id == payload.id
}iflet index = index {
userState.users[index]= payload
}}
// actions
func loadUsers()async{do{let(data, _)=tryawaitURLSession.shared.data(from:URL(string:"https://jsonplaceholder.typicode.com/users/")!)letusers=tryJSONDecoder().decode([User].self, from: data)commit(mutation: SET_USERS, payload: users)}catch{print(#function, error)commit(mutation: SET_USERS, payload:[])}}func update(user:User)asyncthrows{letparams=tryJSONEncoder().encode(user)varrequest=URLRequest(url:URL(string:"https://jsonplaceholder.typicode.com/users/\(user.id)")!)
request.httpMethod ="PUT"
request.httpBody = params
request.addValue("application/json", forHTTPHeaderField:"Content-Type")
request.addValue("application/json", forHTTPHeaderField:"Accept")let(data, _)=tryawaitURLSession.shared.data(for: request)letuser=tryJSONDecoder().decode(User.self, from: data)commit(mutation: SET_USER, payload: user)}}You can test the above UserStore as follows.
import XCTest
@testableimport Redux
finalclassUserStoreTests:XCTestCase{privatevarstore:UserStore!overridefunc setUp(){
super.setUp()
store =UserStore()}overridefunc tearDown(){
super.tearDown()
store =nil}func testInitialState(){XCTAssertEqual([], store.state.users)}func testLoadUsers()async{await store.loadUsers()XCTAssertEqual(10, store.state.users.count)foruserin store.state.users {XCTAssertGreaterThan(user.id,0)XCTAssertNotEqual(user.name,"")}}func testUpdateUser()async{do{await store.loadUsers()XCTAssertEqual(10, store.state.users.count)varmutableUser= store.state.users[0]
mutableUser.name ="Sungcheol Kim"tryawait store.update(user: mutableUser)XCTAssertEqual(10, store.state.users.count)letuser= store.state.users[0]XCTAssertEqual("Sungcheol Kim", user.name)}catch{XCTFail("Failed update user")}}}It is necessary to manage the app status in one place with Single Source of Truth. In that case, it is dangerous to define all states of the app in one state. Therefore, it is recommended to divide the state into modules and create and manage a store that manages each state. You can define the App Store as below.
import Foundation
import Redux
structAppState:State{}classAppStore:Store<AppState>{
// composition store
letcounter=CounterStore()letusers=UserStore()init(){
super.init(state:AppState())}}You can use the AppStore above as follows.
import XCTest
@testableimport Redux
// Single Source of Truth
finalclassSSOTTests:XCTestCase{privatevarstore:AppStore!overridefunc setUp(){
super.setUp()
store =AppStore()}overridefunc tearDown(){
super.tearDown()
store =nil}func testLoadUsers()async{await store.users.loadUsers()XCTAssertEqual(10, store.users.state.users.count)foruserin store.users.state.users {XCTAssertGreaterThan(user.id,0)XCTAssertNotEqual(user.name,"")}}func testUpdateUser()async{do{await store.users.loadUsers()XCTAssertEqual(10, store.users.state.users.count)varmutableUser= store.users.state.users[0]
mutableUser.name ="Sungcheol Kim"tryawait store.users.update(user: mutableUser)XCTAssertEqual(10, store.users.state.users.count)letuser= store.users.state.users[0]XCTAssertEqual("Sungcheol Kim", user.name)}catch{XCTFail("Failed update user")}}func testIncrementAction(){
store.counter.incrementAction(payload:1)XCTAssertEqual(1, store.counter.state.count)
store.counter.incrementAction(payload:10)XCTAssertEqual(11, store.counter.state.count)}func testPublisherValue(){XCTAssertEqual(0, store.counter.count)
store.counter.incrementAction(payload:1)XCTAssertEqual(1, store.counter.count)
store.counter.incrementAction(payload:10)XCTAssertEqual(11, store.counter.count)
store.counter.decrementAction(payload:10)XCTAssertEqual(1, store.counter.count)
store.counter.decrementAction(payload:1)XCTAssertEqual(0, store.counter.count)}func testAsyncIncrementAction()async{await store.counter.asyncIncrementAction(payload:1)XCTAssertEqual(1, store.counter.state.count)XCTAssertEqual(1, store.counter.count)await store.counter.asyncIncrementAction(payload:10)XCTAssertEqual(11, store.counter.state.count)XCTAssertEqual(11, store.counter.count)}func testAsyncDecrementAction()async{await store.counter.asyncDecrementAction(payload:1)XCTAssertEqual(-1, store.counter.state.count)XCTAssertEqual(-1, store.counter.count)await store.counter.asyncDecrementAction(payload:10)XCTAssertEqual(-11, store.counter.state.count)XCTAssertEqual(-11, store.counter.count)}}We can define the AppStore like as below but it is not a good design. If you add more state to the AppState, the AppStore becomes more massive store.
structAppState:State{varcount:Int=0varcontent:String?=nilvarerror:String?=nil}classAppStore:Store<AppState>{init(){
super.init(state:AppState())}@Publishedvarcount:Int=0@Publishedvarcontent:String?=nil@Publishedvarerror:String?=niloverridefunc computed(new:AppState, old:AppState){if(self.count != new.count){self.count = new.count
}if(self.content != new.content){self.content = new.content
}if(self.error != new.error){self.error = new.error
}}overridefunc worksAfterCommit()->[(AppState)->Void]{return[{ state inprint(state.count)}]}privatefunc INCREMENT(state:inoutAppState, payload:Int){
state.count += payload
}privatefunc DECREMENT(state:inoutAppState, payload:Int){
state.count -= payload
}privatefunc SET_CONTENT(state:inoutAppState, payload:String){
state.content = payload
}privatefunc SET_ERROR(state:inoutAppState, payload:String?){
state.error = payload
}func incrementAction(payload:Int){commit(mutation: INCREMENT, payload: payload)}func decrementAction(payload:Int){commit(mutation: DECREMENT, payload: payload)}func fetchContent()async{do{let(data, _)=tryawaitURLSession.shared.data(from:URL(string:"https://www.facebook.com")!)letvalue=String(data: data, encoding:.utf8)??""commit(mutation: SET_ERROR, payload:nil)commit(mutation: SET_CONTENT, payload: value)}catch{commit(mutation: SET_ERROR, payload: error.localizedDescription)}}}It is a good practice to break the state into small pieces and then compose them to one store.
/**
* CounterStore.swift
*/
structCounter:State{varcount:Int=0}classCounterStore:Store<Counter>{@Publishedvarcount:Int=0overridefunc computed(new:Counter, old:Counter){if(self.count != new.count){self.count = new.count
}}init(){
super.init(state:Counter())}overridefunc worksAfterCommit()->[(Counter)->Void]{return[{ state inprint(state.count)}]}privatefunc INCREMENT(state:inoutCounter, payload:Int){
state.count += payload
}privatefunc DECREMENT(state:inoutCounter, payload:Int){
state.count -= payload
}func incrementAction(payload:Int){commit(mutation: INCREMENT, payload: payload)}func decrementAction(payload:Int){commit(mutation: DECREMENT, payload: payload)}}
/**
* ContentStore.swift
*/
structContent:State{varvalue:String?=nilvarerror:String?=nil}classContentStore:Store<Content>{@Publishedvarvalue:String?=nil@Publishedvarerror:String?=niloverridefunc computed(new:Content, old:Content){if(self.value != new.value){self.value = new.value
}if(self.error != new.error){self.error = new.error
}}init(){
super.init(state:Content())}overridefunc worksAfterCommit()->[(Content)->Void]{return[{ state inprint(state.value ??"없음")}]}privatefunc SET_CONTENT_VALUE(state:inoutContent, payload:String){
state.value = payload
}privatefunc SET_ERROR(state:inoutContent, payload:String?){
state.error = payload
}func fetchContentValue()async{do{let(data, _)=tryawaitURLSession.shared.data(from:URL(string:"https://www.facebook.com")!)letvalue=String(data: data, encoding:.utf8)??""commit(mutation: SET_ERROR, payload:nil)commit(mutation: SET_CONTENT_VALUE, payload: value)}catch{commit(mutation: SET_ERROR, payload: error.localizedDescription)}}}
/**
* ComposeAppStore.swift
*/
structComposeAppState:State{
// A state that depends on the state of another store.
varallLength:String=""}classComposeAppStore:Store<ComposeAppState>{letcounter=CounterStore();
letcontent=ContentStore();
// Set it to private to access counter.count with the counter namespace in the UI layer.
@Publishedprivatevarcount=0;
@PublishedprivatevarcontentValue:String?=nil;
@Publishedprivatevarerror:String?=nil;
@PublishedvarallLength:String?=nil;
overridefunc computed(new:ComposeAppState, old:ComposeAppState){if(new.allLength != old.allLength){self.allLength = new.allLength
}}init(){
super.init(state:ComposeAppState())
// @Published chaining is required.
counter.$count.assign(to:&self.$count)
content.$value.assign(to:&self.$contentValue)
content.$error.assign(to:&self.$error)}
//Examples of actions and state mutations that depend on the state and actions of other stores are
privatefunc SET_ALL_LENGTH(state:inoutComposeAppState, payload:String){
state.allLength = payload
}func someComposeAction()async{await content.fetchContentValue()commit(mutation: SET_ALL_LENGTH, payload:"counter: \(counter.state.count), content: \(content.state.value?.count ??0)")}}
/**
* ContentView.swift
*/
import SwiftUI
structContentView:View{@EnvironmentObjectprivatevarstore:ComposeAppStorevarbody:someView{VStack{Text("\(store.counter.count)").font(.title).bold().padding()iflet error = store.content.error {Text("Error! \(error)")}HStack{Spacer()Button(action:{ store.counter.decrementAction(payload:1)}){Text(" - ").font(.title).bold()}Spacer()Button(action:{ store.counter.incrementAction(payload:1)}){Text(" + ").font(.title).bold()}Spacer()}VStack{Button(action:{Task{await store.someComposeAction()}}){Text("All Length").bold().multilineTextAlignment(.center)}Text(store.allLength ??"").foregroundColor(.red).font(.system(size:12)).lineLimit(5)Button(action:{Task{await store.content.fetchContentValue()}}){Text("Fetch Content").bold().multilineTextAlignment(.center)}Text(store.content.value ??"").foregroundColor(.red).font(.system(size:12)).lineLimit(5)}}.padding(.horizontal,100)}}Copyright (c) 2021 Redux, ReactComponentKit
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
