SkillAgentSearch skills...

OneWay

A Swift library for state management with unidirectional data flow.

Install / Use

/learn @DevYeom/OneWay
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

<img src="https://github.com/DevYeom/OneWay/blob/assets/oneway_logo.png" alt="oneway_logo"/> <p align="center"> <a href="https://github.com/DevYeom/OneWay/actions/workflows/ci.yml"> <img alt="CI" src="https://github.com/DevYeom/OneWay/actions/workflows/ci.yml/badge.svg"> </a> <a href="https://github.com/DevYeom/OneWay/releases/latest"> <img alt="release" src="https://img.shields.io/github/v/release/DevYeom/OneWay?logo=swift&color=orange"> </a> <a href="LICENSE"> <img alt="license" src="https://img.shields.io/badge/license-MIT-lightgray.svg"> </a> </p> <p align="center"> <a href="https://swiftpackageindex.com/DevYeom/OneWay"> <img alt="Swift" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FDevYeom%2FOneWay%2Fbadge%3Ftype%3Dswift-versions"> </a> <a href="https://swiftpackageindex.com/DevYeom/OneWay"> <img alt="Platforms" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FDevYeom%2FOneWay%2Fbadge%3Ftype%3Dplatforms"> </a> </p>

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. Its design ensures thread safety at all times.

It integrates effortlessly across all Apple 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 complex business logic. If you are looking to implement unidirectional logic, OneWay is a straightforward and practical solution.

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.

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.

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.

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.

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.

// 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 and @Ignored, please refer to the documentation.

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, 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"

Integrating with SwiftUI

OneWay 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 method that makes it easy to create a 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 cancellable by using the cancellable() method. You can then use cancel() to cancel the 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 use anything that conforms to the Hashable protocol as an identifier for an 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 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.

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 aw
View on GitHub
GitHub Stars103
CategoryDevelopment
Updated3mo ago
Forks11

Languages

Swift

Security Score

97/100

Audited on Dec 11, 2025

No findings