OneWay

A Swift library for state management with unidirectional data flow.

87
9
Swift
oneway_logo

CI release license

Swift Platforms

OneWay is a simple, lightweight library for state management using a unidirectional data flow, fully compatiable with Swift 6 and built on Swift Concurrency. Its structure makes it easier to maintain thread safety at all times.

It integrates effortlessly across platforms and frameworks, with zero third-party dependencies, allowing you to use it in its purest form. OneWay can be used anywhere, not just in the presentation layer, to simplify the complex business logic. If you’re looking to implement unidirectional logic, OneWay is a straightforward and practical solution.

Data Flow

When using the Store, the data flow is as follows.

flow_description_1

When working on UI, it is better to use ViewStore to ensure main thread operation.

flow_description_1

Usage

Implementing a Reducer

After adopting the Reducer protocol, define the Action and State, and then implement the logic for each Action within the reduce(state:action:) function.

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 in the state via Reducer.

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 ViewStore. However, when working within MainActor, such as in UIViewController or View’s body, await can be omitted.

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 States

When the state changes, you can receive a new state. It guarantees that the same state does not come down consecutively.

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 observe specific properties only.

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

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

If you want to continue receiving the value even when the same value is assigned to the State, you can use @Triggered. For explanations of other useful property wrappers(e.g. @CopyOnWrite, @Ignored), refer to here.

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 of the state, it is possible for the state to change due to other properties that are not subscribed to. In such cases, if you are using AsyncAlgorithms, you can remove duplicates as follows.

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"

Integration with SwiftUI

It can be seamlessly integrated with SwiftUI.

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 function that makes it easy to create Binding.

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.

Cancelling Effects

You can make an effect capable of being canceled by using cancellable(). And you can use cancel() to cancel a cancellable effect.

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 assign anything that conforms Hashable as an identifier for the effect, not just a string.

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.

External States

You can easily receive to external states by implementing bind(). If there are changes in publishers or streams that necessitate rebinding, you can call reset() of Store.

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 the expect function to help you write concise and clear tests. This function works asynchronously, allowing you to verify whether the state updates as expected.

Before using the expect function, make sure to import the OneWayTesting module.

import OneWayTesting

When using Testing

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

@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 within the XCTest environment.

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

Documentation

To learn how to use OneWay in more detail, go through the documentation.

Examples

Requirements

OneWay Swift Xcode Platforms
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 only supported by Swift Package Manager.

To integrate OneWay into your Xcode project using Swift Package Manager, add it to the dependencies value of your Package.swift:

dependencies: [
  .package(url: "https://github.com/DevYeom/OneWay", from: "2.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.