Contents

devyeom/oneway

**OneWay** is a simple, lightweight library for state management that uses a unidirectional data flow. It is fully compatible with Swift 6 and is built on [Swift Concurrency](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/). Its design

Features

  • 🕊️ Lightweight and Simple: A straightforward implementation of unidirectional data flow.
  • 🔐 Thread-Safe: Built on Swift Concurrency to ensure thread safety.
  • 🗂️ Decoupled: Aims for a clean separation between the view and business logic.
  • 🥗 Flexible: Can be used in any part of your application, not just the presentation layer.
  • 🧪 Testable: Provides a testing module to facilitate writing unit tests.
  • No Dependencies: Has zero third-party dependencies.

Data Flow

When using a Store, the data flows in a single direction.

<img src="https://github.com/DevYeom/OneWay/blob/assets/flow_description_v2_1.png" alt="flow_description_1"/>

When working with UI, it is better to use a ViewStore to ensure all operations are performed on the main thread.

<img src="https://github.com/DevYeom/OneWay/blob/assets/flow_description_v2_2.png" alt="flow_description_1"/>

Usage

### Implementing a Reducer

First, conform to the `Reducer` protocol, define your `Action` and `State`, and then implement the logic for each `Action` in the `reduce(state:action:)` method.

```swift
struct CountingReducer: Reducer {
    enum Action: Sendable {
        case increment
        case decrement
        case twice
        case setIsLoading(Bool)
    }

    struct State: Sendable, Equatable {
        var number: Int
        var isLoading: Bool
    }

    func reduce(state: inout State, action: Action) -> AnyEffect<Action> {
        switch action {
        case .increment:
            state.number += 1
            return .none
        case .decrement:
            state.number -= 1
            return .none
        case .twice:
            return .concat(
                .just(.setIsLoading(true)),
                .merge(
                    .just(.increment),
                    .just(.increment)
                ),
                .just(.setIsLoading(false))
            )
        case .setIsLoading(let isLoading):
            state.isLoading = isLoading
            return .none
        }
    }
}
```

### Sending Actions

Sending an action to a **Store** causes changes to the `state` through the `Reducer`.

```swift
let store = Store(
    reducer: CountingReducer(),
    state: CountingReducer.State(number: 0)
)

await store.send(.increment)
await store.send(.decrement)
await store.send(.twice)

print(await store.state.number) // 2
```

The usage is the same for a `ViewStore`. However, when working within a `MainActor`, such as in a `UIViewController` or a `View`'s body, you can omit `await`.

```swift
let store = ViewStore(
    reducer: CountingReducer(),
    state: CountingReducer.State(number: 0)
)

store.send(.increment)
store.send(.decrement)
store.send(.twice)

print(store.state.number) // 2
```

### Observing State

When the state changes, you will receive the new state. It is guaranteed that the same state will not be emitted consecutively.

```swift
struct State: Sendable, Equatable {
    var number: Int
}

// number <- 10, 10, 20 ,20

for await state in store.states {
    print(state.number)
}
// Prints "10", "20"
```

Of course, you can also observe specific properties.

```swift
// number <- 10, 10, 20 ,20

for await number in store.states.number {
    print(number)
}
// Prints "10", "20"
```

If you want to continue receiving values even when the same value is assigned to the `State`, you can use the `@Triggered` property wrapper. For explanations of other useful property wrappers, such as [@CopyOnWrite](https://swiftpackageindex.com/devyeom/oneway/main/documentation/oneway/copyonwrite) and [@Ignored](https://swiftpackageindex.com/devyeom/oneway/main/documentation/oneway/ignored), please refer to the [documentation](https://swiftpackageindex.com/devyeom/oneway/main/documentation/oneway/triggered).

```swift
struct State: Sendable, Equatable {
    @Triggered var number: Int
}

// number <- 10, 10, 20 ,20

for await state in store.states {
    print(state.number)
}
// Prints "10", "10", "20", "20"
```

When there are multiple properties in the state, it is possible for the state to change due to other properties that you are not subscribed to. In such cases, if you are using [AsyncAlgorithms](https://github.com/apple/swift-async-algorithms), you can remove duplicates as follows.

```swift
struct State: Sendable, Equatable {
    var number: Int
    var text: String
}

// number <- 10
// text <- "a", "b", "c"

for await number in store.states.number {
    print(number)
}
// Prints "10", "10", "10"

for await number in store.states.number.removeDuplicates() {
    print(number)
}
// Prints "10"
```

### Integrating with SwiftUI

**OneWay** can be seamlessly integrated with [SwiftUI](https://developer.apple.com/documentation/swiftui).

```swift
struct CounterView: View {
    @StateObject private var store = ViewStore(
        reducer: CountingReducer(),
        state: CountingReducer.State(number: 0)
    )

    var body: some View {
        VStack {
            Text("\(store.state.number)")
            Toggle(
                "isLoading",
                isOn: Binding<Bool>(
                    get: { store.state.isLoading },
                    set: { store.send(.setIsLoading($0)) }
                )
            )
        }
        .onAppear {
            store.send(.increment)
        }
    }
}
```

There is also a helper method that makes it easy to create a [Binding](https://developer.apple.com/documentation/swiftui/binding).

```swift
struct CounterView: View {
    @StateObject private var store = ViewStore(
        reducer: CountingReducer(),
        state: CountingReducer.State(number: 0)
    )

    var body: some View {
        VStack {
            Text("\(store.state.number)")
            Toggle(
                "isLoading",
                isOn: store.binding(\.isLoading, send: { .setIsLoading($0) })
            )
        }
        .onAppear {
            store.send(.increment)
        }
    }
}
```

For more details, please refer to the [examples](#examples).

### Cancelling Effects

You can make an effect cancellable by using the `cancellable()` method. You can then use `cancel()` to cancel the effect.

```swift
func reduce(state: inout State, action: Action) -> AnyEffect<Action> {
    switch action {
// ...
    case .request:
        return .single {
            let result = await api.result()
            return Action.response(result)
        }
        .cancellable("requestID")

    case .cancel:
        return .cancel("requestID")
// ...
    }
}
```

You can use anything that conforms to the [Hashable](https://developer.apple.com/documentation/swift/hashable) protocol as an identifier for an effect, not just a string.

```swift
enum EffectID {
    case request
}

func reduce(state: inout State, action: Action) -> AnyEffect<Action> {
    switch action {
// ...
    case .request:
        return .single {
            let result = await api.result()
            return Action.response(result)
        }
        .cancellable(EffectID.request)

    case .cancel:
        return .cancel(EffectID.request)
// ...
    }
}
```

### Various Effects

**OneWay** supports various effects, such as `just`, `concat`, `merge`, `single`, `sequence`, and more. For more details, please refer to the [documentation](https://swiftpackageindex.com/devyeom/oneway/main/documentation/oneway/effects).

### External State

You can easily subscribe to external state changes by implementing the `bind()` method. If there are changes in publishers or streams that require re-binding, you can call the `reset()` method of the `Store`.

```swift
let textPublisher = PassthroughSubject<String, Never>()
let numberPublisher = PassthroughSubject<Int, Never>()

struct CountingReducer: Reducer {
// ...
    func bind() -> AnyEffect<Action> {
        return .merge(
            .sequence { send in
                for await text in textPublisher.values {
                    send(Action.response(text))
                }
            },
            .sequence { send in
                for await number in numberPublisher.values {
                    send(Action.response(String(number)))
                }
            }
        )
    }
// ...
}
```

### Testing

**OneWay** provides an `expect` function to help you write concise and clear tests. This function works asynchronously, allowing you to verify that the state updates as expected.

Before using the `expect` function, be sure to import the **OneWayTesting** module.

```swift
import OneWayTesting
```

#### When using Testing

You can use the `expect` function to easily check the state value.

```swift
@Test
func incrementTwice() async {
    await sut.send(.increment)
    await sut.send(.increment)

    await sut.expect(\.count, 2)
}
```

#### When using XCTest

The `expect` function is used in the same way in an `XCTest` environment.

```swift
func test_incrementTwice() async {
    await sut.send(.increment)
    await sut.send(.increment)

    await sut.expect(\.count, 2)
}
```

For more details, please refer to the [Testing](https://swiftpackageindex.com/DevYeom/OneWay/main/documentation/OneWay/Testing) article.

Documentation

To learn how to use OneWay in more detail, please refer to the documentation.

Examples

- UIKit - SwiftUI

  • badabook-ios: A multi-platform application based on Clean Architecture.

Requirements

[!NOTE] While the minimum requirement for OneWay 3.0 is Swift 6, it is compatible with Swift 5 when used with Xcode 16 or later.

| OneWay | Swift | Xcode | Platforms | |--------|-------|-------|-------------------------------------------------------------| | 3.0 | 6.0 | 16.0 | iOS 16.0, macOS 13, tvOS 16.0, visionOS 1.0, watchOS 9.0 | | 2.0 | 5.9 | 15.0 | iOS 13.0, macOS 10.15, tvOS 13.0, visionOS 1.0, watchOS 6.0 | | 1.0 | 5.5 | 13.0 | iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0 |

Installation

OneWay is exclusively supported by the Swift Package Manager.

To integrate OneWay into your Xcode project using the Swift Package Manager, add it to the dependencies in your Package.swift file.

dependencies: [
  .package(url: "https://github.com/DevYeom/OneWay", from: "3.0.0"),
]

References

These are the references that have provided much inspiration.

License

This library is released under the MIT license. See LICENSE for details.

Package Metadata

Repository: devyeom/oneway

Default branch: main

README: README.md