Redux has gained immense popularity for managing application state in a predictable and maintainable manner. This comprehensive guide walks through the process of implementing Redux in an iOS app, empowering developers to build scalable and efficient applications. From understanding core principles to practical implementation, this guide serves as a robust resource for adopting Redux in iOS development
Understanding Redux Architecture: the core principles - single source of truth, state immutability, and unidirectional data flow, where:
Setting Up the Project. Create a new iOS project. Initialize the project structure, including folders Core, Flow, MW, Store, Services, where:
Defining Actions. At first make protocol for actions and at the same time include reducer. Define actions that represent events triggering state changes. Organize actions into separate files based on functionality.
Implementing Reducers. Create reducers that handle actions and update the state immutably. Combine reducers to manage different parts of the state
protocol Action {
associatedtype StateType: Equatable
func reduce(_ state: inout StateType)
}
enum MainAction: Action {
case user(UserAction)
func reduce(_ state: inout MainState) {
switch self {
case let .user(action): action.reduce(&state)
}
}
}
enum UserAction: Action {
case getUserData
case receiveUser(UserModel)
func reduce(_ state: inout MainState) {
switch self {
case .getUserData: state.user = .loading
case let .receiveUser(model): state.user = .result(model)
}
}
}
Managing Asynchronous Operations. Make base elements and protocol for Middleware. Implement middleware to manage asynchronous actions. And create DTO with mapping to State model
struct Box<Act> where Act: Action {
let run: (@escaping (Act) -> Void) -> Void
init(_ run: @escaping (@escaping (Act) -> Void) -> Void) {
self.run = run
}
}
extension Box {
static func throwTask(
_ run: @escaping (@escaping (Act) -> Void) async throws -> Void,
catchError: @escaping (@escaping (Act) -> Void, Error) -> Void = { _, _ in }
) -> Self {
Self { dispatcher in
Task {
do {
try await run(dispatcher)
} catch {
catchError(dispatcher, error)
}
}
}
}
}
extension Box {
static var zero: Self { Self { _ in }}
static func +=(lhs: inout Self, rhs: Self) {
let tmp = lhs
lhs = Self { dispatcher in
tmp.run(dispatcher)
rhs.run(dispatcher)
}
}
}
protocol Middleware {
associatedtype Act where Act: Action
func run(state: Act.StateType, action: Act) -> Box<Act>
}
extension Middleware {
var asAnyMiddleware: AnyMiddleware<Act> {
AnyMiddleware(self.run(state:action:))
}
}
struct AnyMiddleware<Act>: Middleware where Act: Action {
private let runer: (Act.StateType, Act) -> Box<Act>
init(_ run: @escaping (Act.StateType, Act) -> Box<Act>) {
self.runer = run
}
func run(state: Act.StateType, action: Act) -> Box<Act> {
runer(state, action)
}
}
struct UserMW: Middleware {
private let net: UserNetProvider
init(net: UserNetProvider) {
self.net = net
}
func run(state: MainState, action: MainAction) -> Box<MainAction> {
switch action {
case let .user(userAction): return handleUser(state: state.user, action: userAction)
default: return .zero
}
}
func handleUser(state: ModelWrapper<UserModel>, action: UserAction) -> Box<MainAction> {
switch action {
case .getUserData: return Box.throwTask { dispatcher in
let userDTO: UserDTO = try await net.getUserData()
dispatcher(.user(.receiveUser(userDTO.convert)))
} catchError: { dispatcher, error in
// Handle error
}
default: return .zero
}
}
}
struct UserDTO: Decodable {
let name: String
let age: Int
var convert: UserModel {
UserModel(name: name, age: age)
}
}
Implementing the Store. Create State and Model wrapper for implementing all states. Create a store to manage the application state. Define the initial state and configure the store.
Of course Store implementation is very simple without corner cases
enum ModelWrapper<Model> where Model: Equatable {
case initialize
case loading
case error(Error)
case result(Model)
}
extension ModelWrapper: Equatable {
private var equatabeValue: String {
return switch self {
case .initialize: "initialize \(String(describing: Model.self))"
case .loading: "loading \(String(describing: Model.self))"
case let .error(error): error.localizedDescription
case let .result(model): String(describing: model)
}
}
static func == (lhs: ModelWrapper<Model>, rhs: ModelWrapper<Model>) -> Bool {
lhs.equatabeValue == rhs.equatabeValue
}
}
struct MainState: Equatable {
public internal(set) var user: ModelWrapper<UserModel> = .initialize
}
struct UserModel: Equatable {
let name: String
let age: Int
}
actor Store<Act> where Act: Action {
nonisolated var statePublisher: AnyPublisher<Act.StateType, Never> {
stateSubject.eraseToAnyPublisher()
}
private var state: Act.StateType {
didSet {
stateSubject.send(state)
}
}
private let stateSubject: CurrentValueSubject<Act.StateType, Never>
private let middlewares: [AnyMiddleware<Act>]
init(initState: Act.StateType, middlewares: [AnyMiddleware<Act>]) {
self.state = initState
self.stateSubject = CurrentValueSubject(initState)
self.middlewares = middlewares
}
func dispatch(action: Act) {
var box = Box<Act>.zero
for mw in middlewares {
box += mw.run(state: state, action: action)
}
action.reduce(&state)
box.run(dispatch(action:))
}
}
Connecting Components to Redux. Integrate Redux with view components to read state from the store. Create simple View with ViewController and dispatch actions to trigger state updates.
class ReduxVC<Act>: UIViewController where Act: Action {
private let store: Store<Act>
private var box = Set<AnyCancellable>()
init(store: Store<Act>) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func sink<S: Equatable>(map: KeyPath<Act.StateType, S>, _ sink: @escaping (_ storeState: S) -> Void) {
store
.statePublisher
.map(map)
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink(receiveValue: sink)
.store(in: &box)
}
func dispatch(_ action: Act) {
Task { await store.dispatch(action: action) }
}
}
final class UserScreenView: UIView {
private let spiner = {
$0.isHidden = true
$0.color = .black
$0.translatesAutoresizingMaskIntoConstraints = false
return $0
}(UIActivityIndicatorView(style: .medium))
private let dataView = {
$0.isHidden = true
$0.backgroundColor = .lightGray
$0.clipsToBounds = true
$0.layer.cornerRadius = 10
$0.translatesAutoresizingMaskIntoConstraints = false
return $0
}(UIView())
private let errorView = {
$0.isHidden = true
$0.backgroundColor = .lightGray
$0.clipsToBounds = true
$0.layer.cornerRadius = 10
$0.translatesAutoresizingMaskIntoConstraints = false
return $0
}(UIView())
private let nameLabel = {
$0.font = .systemFont(ofSize: 22, weight: .regular)
$0.textColor = .black
$0.translatesAutoresizingMaskIntoConstraints = false
return $0
}(UILabel())
private let ageLabel = {
$0.font = .systemFont(ofSize: 22, weight: .regular)
$0.textColor = .black
$0.translatesAutoresizingMaskIntoConstraints = false
return $0
}(UILabel())
private let errorLabel = {
$0.font = .systemFont(ofSize: 22, weight: .regular)
$0.textColor = .systemRed
$0.translatesAutoresizingMaskIntoConstraints = false
return $0
}(UILabel())
init() {
super.init(frame: .zero)
backgroundColor = .white
addSubview(spiner)
addSubview(dataView)
dataView.addSubview(nameLabel)
dataView.addSubview(ageLabel)
addSubview(errorView)
errorView.addSubview(errorLabel)
NSLayoutConstraint.activate([
spiner.centerXAnchor.constraint(equalTo: centerXAnchor),
spiner.centerYAnchor.constraint(equalTo: centerYAnchor),
spiner.heightAnchor.constraint(equalToConstant: 30),
spiner.widthAnchor.constraint(equalToConstant: 30),
dataView.centerXAnchor.constraint(equalTo: centerXAnchor),
dataView.centerYAnchor.constraint(equalTo: centerYAnchor),
nameLabel.leadingAnchor.constraint(equalTo: dataView.leadingAnchor, constant: 22),
nameLabel.trailingAnchor.constraint(equalTo: dataView.trailingAnchor, constant: -22),
nameLabel.topAnchor.constraint(equalTo: dataView.topAnchor, constant: 22),
ageLabel.widthAnchor.constraint(equalTo: nameLabel.widthAnchor),
ageLabel.centerXAnchor.constraint(equalTo: dataView.centerXAnchor),
ageLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 22),
ageLabel.bottomAnchor.constraint(equalTo: dataView.bottomAnchor, constant: -22),
errorView.centerXAnchor.constraint(equalTo: centerXAnchor),
errorView.centerYAnchor.constraint(equalTo: centerYAnchor),
errorLabel.leadingAnchor.constraint(equalTo: errorView.leadingAnchor, constant: 22),
errorLabel.trailingAnchor.constraint(equalTo: errorView.trailingAnchor, constant: -22),
errorLabel.topAnchor.constraint(equalTo: errorView.topAnchor, constant: 22),
errorLabel.bottomAnchor.constraint(equalTo: errorView.bottomAnchor, constant: -22)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func loadingData() {
dataView.isHidden = true
errorView.isHidden = true
spiner.isHidden = false
spiner.startAnimating()
}
func errorSetup(_ error: String) {
dataView.isHidden = true
spiner.isHidden = true
errorView.isHidden = false
spiner.stopAnimating()
errorLabel.text = error
}
func dataSetup(_ model: UserModel) {
dataView.isHidden = false
spiner.isHidden = true
errorView.isHidden = true
spiner.stopAnimating()
nameLabel.text = model.name
ageLabel.text = String(model.age)
}
}
final class UserScreenVC: ReduxVC<MainAction> {
let mainView = UserScreenView()
override func loadView() {
self.view = mainView
}
override func viewDidLoad() {
super.viewDidLoad()
sink(map: \.user) { [unowned self] state in
switch state {
case .initialize: break
case .loading: mainView.loadingData()
case let .error(err): mainView.errorSetup(err.localizedDescription)
case let .result(model): mainView.dataSetup(model)
}
}
dispatch(.user(.getUserData))
}
}
And finally assembly all components together into SceneDelegate
guard let windowScene = (scene as? UIWindowScene) else { return }
let netProvider = NetProvider()
let store = Store<MainAction>(
initState: MainState(),
middlewares: [
UserMW(net: UserNetProvider(netProvider: netProvider)).asAnyMiddleware
]
)
window = UIWindow(frame: UIScreen.main.bounds)
window?.windowScene = windowScene
window?.rootViewController = UserScreenVC(store: store)
window?.makeKeyAndVisible()
All structural folders Core, Flow, MW, Store, Services you can separate into separate elements e.g. Frameworks/Swift Packages/Libraries or something like this
Conclusion:
In conclusion, the adoption of Redux architecture in iOS app development offers a structured, scalable, and efficient approach to managing application state. This comprehensive guide has illuminated the core principles of Redux, emphasizing the importance of a single source of truth, state immutability, and unidirectional data flow.
The step-by-step exploration of implementing Redux architecture has covered various key components