A Swift library for state management with unidirectional data flow.
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.
When using the Store
, the data flow is as follows.
When working on UI, it is better to use ViewStore
to ensure main thread operation.
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 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
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"
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.
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)
// ...
}
}
OneWay supports various effects such as just
, concat
, merge
, single
, sequence
, and more. For more details, please refer to the documentation.
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)))
}
}
)
}
// ...
}
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
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)
}
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.
To learn how to use OneWay in more detail, go through the documentation.
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 |
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"),
]
These are the references that have provided much inspiration.
This library is released under the MIT license. See LICENSE for details.