XUtils

SimplexArchitecture

A Simple architecture that decouples state changes from SwiftUI's View


Documentation

The documentation for main are available here:

ReducerAction

If there are Actions that you do not want to expose to View, ReducerAction is effective. This is the sample code:

@Reducer
struct MyReducer {
    enum ViewAction {
        case login
    }

    enum ReducerAction {
        case loginResponse(TaskResult<Response>)
    }

    @Dependency(\.authClient) var authClient

    func reduce(into state: StateContainer<MyView>, action: Action) -> SideEffect<Self> {
        switch action {
        case .login:
            return .run { [email = state.email, password = state.password] send in
                await send(
                    .loginResponse(
                        TaskResult { try await authClient.login(email, password) }
                    )
                )
            }
        case let .loginResponse(result):
            ...
            return .none
        }
    }
}

@ViewState
struct MyView: View {
    @State var email: String = ""
    @State var password: String = ""

    let store: Store<MyReducer>
    ...
}

ReducerState

Use ReducerState if you want to keep the state only in the Reducer. ReducerState is also effective to improve performance because the View is not updated even if the value is changed.

This is the example code

@Reducer
struct MyReducer {
    enum ViewAction {
        case increment
        case decrement
    }

    struct ReducerState {
        var totalCalledCount = 0
    }

    func reduce(into state: StateContainer<MyView>, action: Action) -> SideEffect<Self> {
        state.reducerState.totalCalledCount += 1
        switch action {
        case .increment:
            if state.reducerState.totalCalledCount < 10 {
                state.counter += 1
            }
            return .none
        case .decrement:
            state.counter -= 1
            return .none
        }
    }
}

@ViewState
struct MyView: View {
    ...
    init() {
        store = Store(reducer: MyReducer(), initialReducerState: MyReducer.ReducerState())
    }
    ...
}

Pullback Action

If you want to send the Action of the child Reducer to the parent Reducer, use pullback. This is the sample code.

@ViewState
struct ParentView: View {
    let store: Store<ParentReducer> = Store(reducer: ParentReducer())

    var body: some View {
        ChildView()
            .pullback(to: /ParentReducer.Action.child, parent: self)
    }
}

@Reducer
struct ParentReducer {
    enum ViewAction {
    }
    enum ReducerAction {
        case child(ChildReducer.Action)
    }

    func reduce(into state: StateContainer<ParentView>, action: Action) -> SideEffect<Self> {
        switch action {
        case .child(.onButtonTapped):
            // do something
            return .none
        }
    }
}

Macro

There are two macros in this library:

  • @Reducer
  • @ViewState

@Reducer

@Reducer is a macro that integrates ViewAction and ReducerAction to generate Action.

@Reducer
struct MyReducer {
    enum ViewAction {
        case loginButtonTapped
    }
    enum ReducerAction {
        case loginResponse(TaskResult<User>)
    }
    // expand to ↓
    enum Action {
        case loginButtonTapped
        case loginResponse(TaskResult<User>)
        
        init(viewAction: ViewAction) {
            switch viewAction {
            case .loginButtonTapped:
                self = .loginButtonTapped
            }
        }
        
        init(reducerAction: ReducerAction) {
            switch reducerAction {
            case .loginResponse(let arg1):
                self = .loginResponse(arg1)
            }
        }
    }
    ...
}

Reducer.reduce(into:action:) no longer needs to be prepared for two actions, ViewAction and ReducerAction, but can be integrated into Action.

@ViewState

@ViewState creates a ViewState structure and conforms it to the ActionSendable protocol.

@ViewState
struct MyView: View {
    @State var counter = 0

    let store: Store<MyReducer> = Store(reducer: MyReducer())

    var body: some View {
        VStack {
            Text("\(counter)")
            Button("+") {
                send(.increment)
            }
            Button("-") {
                send(.decrement)
            }
        }
    }
    // expand to ↓
    struct ViewState: ViewStateProtocol {
        var counter = 0
        static let keyPathMap: [PartialKeyPath<ViewState>: PartialKeyPath<MyView>] = [\.counter: \.counter]
    }
}

The ViewState structure serves two main purposes:

  • To make properties such as store and body of View inaccessible to Reducer.
  • To make it testable.

Also, By conforming to the ActionSendable protocol, you can send Actions to the Store.

Testing

For testing, we use TestStore. This requires an instance of the ViewState struct, which is generated by the @ViewState macro. Additionally, we’ll conduct further operations to assert how its behavior evolves when an action is dispatched.

@MainActor
func testReducer() async {
    let store = MyView().testStore(viewState: .init())
}

Each step of the way we need to prove that state changed how we expect. For example, we can simulate the user flow of tapping on the increment and decrement buttons:

@MainActor
func testReducer() async {
    let store = MyView().testStore(viewState: .init())
    await store.send(.increment) {
        $0.count = 1
    }
    await store.send(.decrement) {
        $0.count = 0
    }
}

Furthermore, when effects are executed by steps and data is fed back into the store, it’s necessary to assert on those effects.

@MainActor
func testReducer() async {
    let store = MyView().testStore(viewState: .init())
    await store.send(.fetchData)
    await store.receive(\.fetchDataResponse.success) {
        $0.data = ...
    }
}

If you’re using swift-dependencies, you can perform dependency injection as follows:

let store = MyView().testStore(viewState: .init()) {
    $0.apiClient.fetchData = { _ in ... }
}

Articles

  • coming soon...