Reducer is unidirectional state machine framework that inspired by ReactorKit & TCA.
- iOS 13.0+
- macOS 10.15+
- macCatalyst 13.0+
- tvOS 13.0+
- watchOS 6.0+
dependencies: [
.package(url: "https://github.com/wlsdms0122/Reducer.git", .upToNextMajor(from: "2.0.0"))
]This guide dose not cover the detailed principles of state design. For more information, plrease refer to the ReactorKit or TCA README.
To get started, You can define it via the @Reduce macro. or you can adopt the Reduce protocol.
By default, Reducer runs on the UI's main thread, while Reduce does not. It's fine to use Reduce without the @MainActor constraint, but if you want the actions to run sequentially, add the @MainActor annotation.
⚠️ When implementingReduce, there is a slight difference in the use of macro and protocol.
@Reduce
@MainActor
final class CounterReduce {
// User interaction input.
enum Action {
case increase
}
// Unit of state mutation.
enum Mutation {
case setCount(Int)
}
// Reducer state.
struct State {
var count: Int
}
let initialState: State
init() {
self.initialState = State()
}
func mutate(action: Action) async throws {
switch action {
case .increase:
mutate(.setCount(currentState.count + 1))
}
}
func reduce(state: State, mutation: Mutation) -> State {
var state = state
switch mutation {
case let .setCount(count):
state.count = count
return state
}
}
}The mutate(action:) async throws method defines what to mutate when an action received with the current state. You can call mutate(_:)(an extended function) to mutate. and Swift Concurrency can be used within the mutate method as well.
reduce(state:mutation) describe how to mutate the state from a mutation. It should be a pure function.
struct CounterView: View {
var body: some Body {
VStack {
Text("\(reducer.state.count)")
Button("Increase") {
reducer.action(.increase)
}
}
}
@StateObject
var reducer = Reducer(CounterReduce()) // Reducer<CounterReduce>
}The Reducer is used to reduce the state using injected Reduce. It is designed for use with SwiftUI and already adopts the ObservableObject protocol.
But it can also be used for UIKit like this. How you use it is up to you.
import Combine
class CounterViewController: UIViewController {
@IBOutlet var countLabel: UILabel!
private let reducer = Reducer(CounterReduce())
private var cancellableBag: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
bind()
}
private func bind() {
reducer.$state.map(\.count)
.removeDuplicates()
.map(String.init)
.assign(to: \.text, on: countLabel)
.store(in: &cancellableBag)
}
@IBAction func increaseTap(_ sender: UIButton) {
reducer.action(.increase)
}
}You can cancel running action task using shouldCancel(_:_:) -> Bool.
For example, if you want to cancel validating user input for each keystroke to efficiently use resources, Reducer can determine whether the current running task should be canceled before creating a new task action. If shouldCancel(_:_:) returns true, the current action should be canceled.
false except for the case you want to cancel.
The second thing to note that canceling a Task doesn't stop your code from progressing. In swift concurrency, cancel of task doesn't has no effect basically.
If you want to make canceling a task meaningful, you'll need to create a cancelable async method or utilize something like Task.checkCancellation().
@Reduce
@MainActor
final class SignUpReduce {
enum Action {
case updateEmail(String)
case anyAction
}
enum Mutation {
case canSignUp(Bool)
}
struct State {
var canSignUp: Bool
}
let initialState: State
private let validator = EmailValidator()
init() {
initialState = State(canSignUp: false)
}
func mutate(action: Action) async throws {
switch action {
case let .updateEmail(email):
let result = try await validator.validate(email)
try Task.checkCancellation()
mutate(.canSignUp(result))
case .anyAction:
...
}
}
...
func shouldCancel(_ current: Action, _ upcoming: Action) -> Bool {
switch (current, upcoming) {
case (.emailChanged, .emailChanged):
return true
default:
return false
}
}
}The reducer sometimes needs to mutate state without explicit outside action like some domain data changed.
In these case, you can use start() function. It call once when Reducer set Reduce. So you can any initialize process with mutations.
@Reduce
@MainActor
final class ListReduce {
enum Action { ... }
enum Mutation {
case setList([Item])
...
}
struct State {
var list: [Item]
...
}
let initialState: State
private var cancellableBag = Set<AnyCancellable>()
init() { ... }
func start() async throws {
// Reset subscription when reduce re-start by reducer.
cancellableBag.removeAll()
NotificationCenter.default.publisher(for: .init("data_changed"))
.sink { [weak self] data in
// Write any mutates here.
self?.mutate(.setList(data.object))
}
.store(in: &cancellableBag)
}
}The Reducer supports proxy reduce for testing.
For example, suppose there is the view that depends on Reducer<CounterReduce> instance. in that case, you can manipulate state using Reducer(proxy:).
struct CounterView: View {
@StateObject
var reducer: Reducer<CounterReduce>
init(reducer: Reducer<CounterReduce>) {
self._reducer = .init(wrappedValue: reducer)
}
}
struct CounterView_Previews: PreviewProvider {
static var previews: some View {
CounterView(reducer: .init(proxy: .init(
initialState: .init(count: 100)
)))
}
}The ProxyReduce is a feature that enables you to manipulate the Reduce instance for testing purposes.
It maipulate all of Reduce even the initialState.
CounterView(reducer: .init(proxy: .init(
initialState: .init(count: 100),
start: { mutate in ... }
mutate: { state, action, mutate in ... },
reduce: { state, mutation in ... },
shouldCancel: { current, upcoming in ... }
)))Any ideas, issues, opinions are welcome.
Reducer is available under the MIT license. See the LICENSE file for more info.