/ReduxUI

💎 Redux like architecture for SwiftUI

Primary LanguageSwiftMIT LicenseMIT

logo

Platform Swift License Platform Version

Simple Architecture like Redux

Installation

SPM

dependencies: [
    .package(url: "https://github.com/gre4ixin/ReduxUI.git", .upToNextMinor(from: "1.0.0"))
]

Usage

import ReduxUI

class SomeCoordinator: Coordinator {
    func perform(_ route: SomeRoute) { }
}

enum SomeRoute: RouteType {

}

enum AppAction: AnyAction {
    case increase
    case decrease
}

struct AppState: AnyState {
    var counter: Int = 0
}

class AppReducer: Reducer {
    typealias Action = AppAction

    func reduce(_ state: inout AppState, action: AppAction, performRoute: @escaping ((_ route: SomeRoute) -> Void)) {
        switch action {
        case .increase:
            state.counter += 1
        case .decrease:
            state.counter -= 1
        }
    }
}

class ContentView: View {
    @EnvironmentObject var store: Store<AppState, AppAction, SomeRouter>

    var body: some View {
        VSTack {
            Text(store.state.counter)

            Button {
                store.dispatch(.increase)
            } label: {
                Text("increment")
            }

            Button {
                store.dispatch(.decrease)
            } label: {
                Text("decrement")
            }
        }
    }
}

class AppModuleAssembly {
    func build() -> some View {
        let reducer = AppReducer().eraseToAnyReducer()
        let coordinator = SomeCoordinator().eraseToAnyCoordinator()
        let store = Store(initialState: AppState(), coordinator: coordinator, reducer: reducer)
        let view = ContentView().environmentObject(store)
        return view
    }
}

That was very simple example, in real life you have to use network request, action in app state changes and many other features. In these cases you can use Middleware.

Middlewares calls after reducer function and return
 AnyPublisher<MiddlewareAction, Never>
For example create simple project who fetch users from https://jsonplaceholder.typicode.com/users.

Create DTO (Decode to object) model

struct UserDTO: Decodable, Equatable, Identifiable {
    let id: Int
    let name: String
    let username: String
    let phone: String
}

Equatable protocol for our state, Identifiable for ForEach generate view in SwiftUI View.

Simple network request without error checking
import Foundation
import Combine

protocol NetworkWrapperInterface {
    func request<D: Decodable>(path: URL, decode: D.Type) -> AnyPublisher<D, NetworkError>
}

struct NetworkError: Error {
    let response: URLResponse?
    let error: Error?
}

class NetworkWrapper: NetworkWrapperInterface {
    
    func request<D: Decodable>(path: URL, decode: D.Type) -> AnyPublisher<D, NetworkError> {
        return Deferred {
            Future<D, NetworkError> { promise in
                let request = URLRequest(url: path)
                URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
                    guard let _ = self else { return }
                    if let _error = error {
                        promise(.failure(NetworkError(response: response, error: _error)))
                    }
                    
                    guard let unwrapData = data, let json = try? JSONDecoder().decode(decode, from: unwrapData) else {
                        promise(.failure(NetworkError(response: response, error: error)))
                        return
                    }
                    
                    promise(.success(json))
                    
                }.resume()
            }
        }.eraseToAnyPublisher()
    }
    
}
Make State, Action and Reducer
enum AppAction: AnyAction {
    case fetch
    case isLoading
    case loadingEnded
    case updateUsers([UserDTO])
    case error(message: String)
}

struct AppState: AnyState {
    var users: [UserDTO] = []
    var isLoading = false
    var errorMessage = ""
}

class AppReducer: Reducer {
    typealias Action = AppAction
    
    func reduce(_ state: inout AppState, action: AppAction, performRoute: @escaping ((RouteWrapperAction) -> Void)) {
        switch action {
        case .fetch:
            state.isLoading = true
            state.errorMessage = ""
        case .isLoading:
            state.isLoading = true
        case .loadingEnded:
            state.isLoading = false
        case .updateUsers(let users):
            state.users = users
            state.isLoading = false
            state.errorMessage = ""
        case .error(let message):
            state.errorMessage = message
        }
    }
}
Middleware for make network request and return users DTO.
class AppMiddleware: Middleware {
    typealias State = AppState
    typealias Action = AppAction
    typealias Router = RouteWrapperAction
    
    let networkWrapper: NetworkWrapperInterface
    
    var cancelabels = CombineBag()
    
    init(networkWrapper: NetworkWrapperInterface) {
        self.networkWrapper = networkWrapper
    }
    
    func execute(_ state: AppState, action: AppAction) -> AnyPublisher<MiddlewareAction<AppAction, RouteWrapperAction>, Never>? {
        switch action {
        case .fetch:
            return Deferred {
                Future<MiddlewareAction<AppAction, RouteWrapperAction>, Never> { [weak self] promise in
                    guard let self = self else { return }
                    self.networkWrapper
                        .request(path: URL(string: "https://jsonplaceholder.typicode.com/users")!, decode: [UserDTO].self)
                        .sink { result in
                            switch result {
                            case .finished: break
                            case .failure(let error):
                                promise(.success(.performAction(.error(message: "Something went wrong!"))))
                            }
                        } receiveValue: { dto in
                            promise(.success(.performAction(.updateUsers(dto))))
                        }.store(in: &self.cancelabels)
                }
            }.eraseToAnyPublisher()
        default:
            return nil
        }
    }
}

Content View

@EnvironmentObject var store: Store<AppState, AppAction, RouteWrapperAction>
    
var body: some View {
    VStack {
        ScrollView {
            ForEach(store.state.users) { user in
                HStack {
                    VStack {
                        Text(user.name)
                            .padding(.leading, 16)
                        Text(user.phone)
                            .padding(.leading, 16)
                    }
                    Spacer()
                }
                Divider()
            }
        }
        Spacer()
        if store.state.isLoading {
            Text("Loading")
        }
        
        if !store.state.errorMessage.isEmpty {
            Text(LocalizedStringKey(store.state.errorMessage))
        }
        
        Button {
            store.dispatch(.fetch)
        } label: {
            Text("fetch users")
        }
    }
}

When reducer ended his job with action, our store check all added middlewares for some Publishers for curent Action, if Publisher not nil, Store runing that Publisher.

You can return action for reducer and change some data, return action for routing, return .multiple actions.

case multiple([MiddlewareAction<A, R>])

You can return Deferred Action.

public protocol DeferredAction {
    associatedtype Action: AnyAction
    func observe() -> AnyPublisher<Action, Never>?
    
    func eraseToAnyDeferredAction() -> AnyDeferredAction<A>
}

If you want route to Authorization, when your Session Provider send event about dead you session, you can use it action. All you need that conform to protocol DeferredAction you class/struct and erase it to AnyDeferredAction with generic Action.