Skip to content

Ryu0118/swiftui-simplex-architecture

Repository files navigation

Simplex Architecture

image

A Library of simple architectures that decouples state changes from SwiftUI's View.

Language:Swift License:MIT Latest Release Twitter

This library is inspired by TCA (swift-composable-architecture), which allows you to decouple the state change logic from the SwiftUI's View and ObservableObject and confine it within the Reducer.

In TCA, integrating child domains into parent domains resulted in higher computational costs, especially at the leaf nodes of the app. Our library addresses this by avoiding the integration of child domains into parent domains, eliminating unnecessary computational overhead. To share values or logic with deeply nested views, we leverage SwiftUI's EnvironmentObject property wrapper. This allows you to seamlessly write logic or state that can be accessed throughout the app. Moreover, our library simplifies the app-building process. You no longer need to remember various TCA modifiers or custom views like ForEachStore, IfLetStore, SwitchStore, sheet(store:), and so on.

Examples

We've provided example implementations within this library. Currently, we only feature a simple GitHub repository search app, but we plan to expand with more examples in the future.

Documentation

The documentation for main are available here:

Installation

let package = Package(
    name: "YourProject",
    ...
    dependencies: [
        .package(url: "https://github.com/Ryu0118/swiftui-simplex-architecture", exact: "0.9.0")
    ],
    targets: [
        .target(
            name: "YourTarget",
            dependencies: [
                .product(name: "SimplexArchitecture", package: "swiftui-simplex-architecture"),
            ]
        )
    ]
)

Basic Usage

The usage is almost the same as in TCA. State definitions use property wrappers used in SwiftUI, such as @State, @Binding, @FocusState.

@Reducer
struct MyReducer {
    enum ViewAction {
        case increment
        case decrement
    }
    func reduce(into state: StateContainer<MyView>, action: Action) -> SideEffect<Self> {
        switch action {
        case .increment:
            state.counter += 1
            return .none
        case .decrement:
            state.counter -= 1
            return .none
        }
    }
}

@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)
            }
        }
    }
}

Events from the View are defined using ViewAction. Actions that should be kept private or used only in the Reducer should use ReducerAction.

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 ... }
}

About

A Library of simple architecture that decouples state changes from SwiftUI's View

Resources

License

Stars

Watchers

Forks

Packages

No packages published