├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Sources
├── UnidirectionalFlow
│ ├── Prism.swift
│ ├── Store.swift
│ ├── Middleware.swift
│ └── Reducer.swift
└── Example
│ └── GithubApp.swift
├── Package.swift
├── LICENSE
├── Tests
└── UnidirectionalFlowTests
│ ├── PrismTests.swift
│ ├── MiddlewareTests.swift
│ ├── ReducerTests.swift
│ └── StoreTests.swift
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/config/registries.json
8 | .swiftpm/xcode/*
9 | .netrc
10 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Sources/UnidirectionalFlow/Prism.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Prism.swift
3 | // UnidirectionalFlow
4 | //
5 | // Created by Majid Jabrayilov on 23.06.22.
6 | //
7 |
8 | /// Type that defines a way to embed and extract a value from another type.
9 | public struct Prism: Sendable {
10 | let embed: @Sendable (Target) -> Source
11 | let extract: @Sendable (Source) -> Target?
12 |
13 | public init(
14 | embed: @Sendable @escaping (Target) -> Source,
15 | extract: @Sendable @escaping (Source) -> Target?
16 | ) {
17 | self.embed = embed
18 | self.extract = extract
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "swift-unidirectional-flow",
8 | platforms: [.iOS(.v17), .macOS(.v14), .tvOS(.v17), .watchOS(.v10), .visionOS(.v1)],
9 | products: [
10 | .library(
11 | name: "UnidirectionalFlow",
12 | targets: ["UnidirectionalFlow"]
13 | ),
14 | ],
15 | dependencies: [],
16 | targets: [
17 | .target(
18 | name: "UnidirectionalFlow",
19 | dependencies: []
20 | ),
21 | .testTarget(
22 | name: "UnidirectionalFlowTests",
23 | dependencies: ["UnidirectionalFlow"]
24 | ),
25 | .target(
26 | name: "Example",
27 | dependencies: ["UnidirectionalFlow"]
28 | )
29 | ]
30 | )
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Majid Jabrayilov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Tests/UnidirectionalFlowTests/PrismTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PrismTests.swift
3 | // UnidirectionalFlowTests
4 | //
5 | // Created by Majid Jabrayilov on 07.07.22.
6 | //
7 | @testable import UnidirectionalFlow
8 | import Testing
9 |
10 | struct PrismTests {
11 | enum LeftAction: Equatable {
12 | case action
13 | }
14 |
15 | enum RightAction: Equatable {
16 | case action
17 | }
18 |
19 | enum Action: Equatable {
20 | case left(LeftAction)
21 | case right(RightAction)
22 |
23 | static var leftPrism: Prism {
24 | Prism(embed: Action.left) {
25 | guard case let Action.left(action) = $0 else {
26 | return nil
27 | }
28 | return action
29 | }
30 | }
31 | }
32 |
33 | @Test func extract() {
34 | #expect(Action.leftPrism.extract(Action.right(.action)) == nil)
35 | #expect(Action.leftPrism.extract(Action.left(.action)) == LeftAction.action)
36 | }
37 |
38 | @Test func embed() {
39 | #expect(Action.leftPrism.embed(.action) == Action.left(.action))
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Sources/Example/GithubApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GithubApp.swift
3 | // Example
4 | //
5 | // Created by Majid Jabrayilov on 17.07.22.
6 | //
7 | import SwiftUI
8 | import UnidirectionalFlow
9 |
10 | struct Repo: Identifiable, Equatable, Decodable {
11 | let id: Int
12 | let name: String
13 | let description: String?
14 | }
15 |
16 | struct SearchResponse: Decodable {
17 | let items: [Repo]
18 | }
19 |
20 | struct SearchState: Equatable {
21 | var repos: [Repo] = []
22 | var isLoading = false
23 | }
24 |
25 | enum SearchAction: Equatable {
26 | case search(query: String)
27 | case setResults(repos: [Repo])
28 | }
29 |
30 | struct SearchReducer: Reducer {
31 | func reduce(oldState: SearchState, with action: SearchAction) -> SearchState {
32 | var state = oldState
33 |
34 | switch action {
35 | case .search:
36 | state.isLoading = true
37 | case let .setResults(repos):
38 | state.repos = repos
39 | state.isLoading = false
40 | }
41 |
42 | return state
43 | }
44 | }
45 |
46 | actor SearchMiddleware: Middleware {
47 | struct Dependencies {
48 | var search: (String) async throws -> SearchResponse
49 |
50 | static var production: Dependencies {
51 | .init { query in
52 | guard var urlComponents = URLComponents(string: "https://api.github.com/search/repositories") else {
53 | return .init(items: [])
54 | }
55 | urlComponents.queryItems = [.init(name: "q", value: query)]
56 |
57 | guard let url = urlComponents.url else {
58 | return .init(items: [])
59 | }
60 |
61 | let (data, _) = try await URLSession.shared.data(from: url)
62 | return try JSONDecoder().decode(SearchResponse.self, from: data)
63 | }
64 | }
65 | }
66 |
67 | let dependencies: Dependencies
68 | init(dependencies: Dependencies) {
69 | self.dependencies = dependencies
70 | }
71 |
72 | func process(state: SearchState, with action: SearchAction) async -> SearchAction? {
73 | switch action {
74 | case let .search(query):
75 | let results = try? await dependencies.search(query)
76 | guard !Task.isCancelled else {
77 | return .setResults(repos: state.repos)
78 | }
79 | return .setResults(repos: results?.items ?? [])
80 | default:
81 | return nil
82 | }
83 | }
84 | }
85 |
86 | typealias SearchStore = Store
87 |
88 | @MainActor struct SearchView: View {
89 | @State private var store = SearchStore(
90 | initialState: .init(),
91 | reducer: SearchReducer(),
92 | middlewares: [SearchMiddleware(dependencies: .production)]
93 | )
94 | @State private var query = ""
95 |
96 | var body: some View {
97 | List(store.repos) { repo in
98 | VStack(alignment: .leading) {
99 | Text(repo.name)
100 | .font(.headline)
101 |
102 | if let description = repo.description {
103 | Text(description)
104 | }
105 | }
106 | }
107 | .redacted(reason: store.isLoading ? .placeholder : [])
108 | .searchable(text: $query)
109 | .task(id: query) {
110 | await store.send(.search(query: query))
111 | }
112 | .navigationTitle("Github Search")
113 | }
114 | }
115 |
116 | @main struct GithubApp: App {
117 | var body: some Scene {
118 | WindowGroup {
119 | NavigationStack {
120 | SearchView()
121 | }
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # swift-unidirectional-flow
2 |
3 | Unidirectional flow implemented using the latest Swift Generics and Swift Concurrency features. To learn more about Unidirectional Flow in Swift, take a look at my dedicated [post](https://swiftwithmajid.com/2023/07/11/unidirectional-flow-in-swift/).
4 |
5 |
6 | ```swift
7 | struct SearchState: Equatable {
8 | var repos: [Repo] = []
9 | var isLoading = false
10 | }
11 |
12 | enum SearchAction: Equatable {
13 | case search(query: String)
14 | case setResults(repos: [Repo])
15 | }
16 |
17 | struct SearchReducer: Reducer {
18 | func reduce(oldState: SearchState, with action: SearchAction) -> SearchState {
19 | var state = oldState
20 |
21 | switch action {
22 | case .search:
23 | state.isLoading = true
24 | case let .setResults(repos):
25 | state.repos = repos
26 | state.isLoading = false
27 | }
28 |
29 | return state
30 | }
31 | }
32 |
33 | actor SearchMiddleware: Middleware {
34 | struct Dependencies {
35 | var search: (String) async throws -> SearchResponse
36 |
37 | static var production: Dependencies {
38 | .init { query in
39 | guard var urlComponents = URLComponents(string: "https://api.github.com/search/repositories") else {
40 | return .init(items: [])
41 | }
42 | urlComponents.queryItems = [.init(name: "q", value: query)]
43 |
44 | guard let url = urlComponents.url else {
45 | return .init(items: [])
46 | }
47 |
48 | let (data, _) = try await URLSession.shared.data(from: url)
49 | return try JSONDecoder().decode(SearchResponse.self, from: data)
50 | }
51 | }
52 | }
53 |
54 | let dependencies: Dependencies
55 | init(dependencies: Dependencies) {
56 | self.dependencies = dependencies
57 | }
58 |
59 | func process(state: SearchState, with action: SearchAction) async -> SearchAction? {
60 | switch action {
61 | case let .search(query):
62 | let results = try? await dependencies.search(query)
63 | guard !Task.isCancelled else {
64 | return .setResults(repos: state.repos)
65 | }
66 | return .setResults(repos: results?.items ?? [])
67 | default:
68 | return nil
69 | }
70 | }
71 | }
72 |
73 | typealias SearchStore = Store
74 |
75 | struct SearchContainerView: View {
76 | @State private var store = SearchStore(
77 | initialState: .init(),
78 | reducer: SearchReducer(),
79 | middlewares: [SearchMiddleware(dependencies: .production)]
80 | )
81 | @State private var query = ""
82 |
83 | var body: some View {
84 | List(store.repos) { repo in
85 | VStack(alignment: .leading) {
86 | Text(verbatim: repo.name)
87 | .font(.headline)
88 |
89 | if let description = repo.description {
90 | Text(verbatim: description)
91 | }
92 | }
93 | }
94 | .redacted(reason: store.isLoading ? .placeholder : [])
95 | .searchable(text: $query)
96 | .task(id: query) {
97 | await store.send(.search(query: query))
98 | }
99 | .navigationTitle("Github Search")
100 | }
101 | }
102 | ```
103 |
104 | ## Installation
105 | Add this Swift package in Xcode using its Github repository url. (File > Swift Packages > Add Package Dependency...)
106 |
107 | * [v0.4.x](https://github.com/mecid/swift-unidirectional-flow/tree/0.3.1) - Introduced Swift 6 strict concurrency support.
108 | * [v0.3.x](https://github.com/mecid/swift-unidirectional-flow/tree/0.3.1) - Introduced the new Observation framework available only on iOS 17 and macOS 14.
109 | * [v0.2.x](https://github.com/mecid/swift-unidirectional-flow/tree/0.2.6) - Use previous versions to target older versions of iOS and macOS.
110 |
111 | ## Author
112 | Majid Jabrayilov: cmecid@gmail.com
113 |
114 | ## License
115 | swift-unidirectional-flow package is available under the MIT license. See the LICENSE file for more info.
116 |
--------------------------------------------------------------------------------
/Tests/UnidirectionalFlowTests/MiddlewareTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MiddlewareTests.swift
3 | // UnidirectionalFlowTests
4 | //
5 | // Created by Majid Jabrayilov on 14.07.22.
6 | //
7 | @testable import UnidirectionalFlow
8 | import Testing
9 |
10 | struct MiddlewareTests {
11 | struct State: Equatable {
12 | var counter: Int
13 | }
14 |
15 | enum Action: Equatable {
16 | case increment
17 | case decrement
18 | }
19 |
20 | typealias Dependencies = Void
21 |
22 | struct CounterMiddleware: Middleware {
23 | func process(state: State, with action: Action) async -> Action? {
24 | switch action {
25 | case .increment: return .decrement
26 | case .decrement: return .increment
27 | }
28 | }
29 | }
30 |
31 | @Test func optional() async {
32 | let state: State? = .init(counter: 1)
33 |
34 | let optional = CounterMiddleware().optional()
35 | let nextAction = await optional.process(state: nil, with: .increment)
36 | #expect(nextAction == nil)
37 |
38 | let anotherAction = await optional.process(state: state, with: .increment)
39 | #expect(anotherAction == .decrement)
40 | }
41 |
42 | @Test func lifted() async {
43 | struct LiftedState: Equatable {
44 | var state = State(counter: 1)
45 | }
46 |
47 | enum LiftedAction: Equatable {
48 | case action(Action)
49 |
50 | static var prism: Prism {
51 | .init(embed: LiftedAction.action) {
52 | guard case let LiftedAction.action(action) = $0 else {
53 | return nil
54 | }
55 | return action
56 | }
57 | }
58 | }
59 |
60 | typealias LiftedDependencies = Void
61 |
62 | let lifted = CounterMiddleware().lifted(
63 | keyPath: \LiftedState.state,
64 | prism: LiftedAction.prism
65 | )
66 |
67 | let nextAction = await lifted.process(state: .init(), with: .action(.increment))
68 | #expect(nextAction == .action(.decrement))
69 | }
70 |
71 | @Test func keyed() async {
72 | struct KeyedState: Equatable {
73 | var keyed = ["key": State(counter: 1)]
74 | }
75 |
76 | enum KeyedAction: Equatable {
77 | case action(String, Action)
78 |
79 | static var prism: Prism {
80 | .init(embed: KeyedAction.action) {
81 | guard case let KeyedAction.action(key, action) = $0 else {
82 | return nil
83 | }
84 | return (key, action)
85 | }
86 | }
87 | }
88 |
89 | let keyed = CounterMiddleware().keyed(
90 | keyPath: \KeyedState.keyed,
91 | prism: KeyedAction.prism
92 | )
93 |
94 | let nextAction = await keyed.process(state: .init(), with: .action("key", .increment))
95 | #expect(nextAction == .action("key", .decrement))
96 |
97 | let nilAction = await keyed.process(state: .init(), with: .action("key1", .increment))
98 | #expect(nilAction == nil)
99 | }
100 |
101 | @Test func offset() async {
102 | struct OffsetState: Equatable {
103 | var state: [State] = [.init(counter: 1)]
104 | }
105 |
106 | enum OffsetAction: Equatable {
107 | case action(Int, Action)
108 |
109 | static var prism: Prism {
110 | .init(embed: OffsetAction.action) {
111 | guard case let OffsetAction.action(offset, action) = $0 else {
112 | return nil
113 | }
114 | return (offset, action)
115 | }
116 | }
117 | }
118 |
119 | typealias OffsetDependencies = Void
120 |
121 | let offset = CounterMiddleware().offset(
122 | keyPath: \OffsetState.state,
123 | prism: OffsetAction.prism
124 | )
125 |
126 | let nextAction = await offset.process(state: .init(), with: .action(0, .increment))
127 | #expect(nextAction == .action(0, .decrement))
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/Sources/UnidirectionalFlow/Store.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Store.swift
3 | // UnidirectionalFlow
4 | //
5 | // Created by Majid Jabrayilov on 11.06.22.
6 | //
7 | import Observation
8 |
9 | /// Type that stores the state of the feature or module allowing feeding actions.
10 | ///
11 | /// The ``Store`` type serves as the single source of truth for your state and handles state mutations through actions.
12 | /// It coordinates between the state, reducer, and middlewares to maintain a predictable state container.
13 | @Observable @dynamicMemberLookup @MainActor public final class Store {
14 | private var state: State
15 | private let reducer: any Reducer
16 | private let middlewares: any Collection>
17 |
18 | /// Creates an instance of `Store` with the folowing parameters.
19 | public init(
20 | initialState state: State,
21 | reducer: some Reducer,
22 | middlewares: some Collection>
23 | ) {
24 | self.state = state
25 | self.reducer = reducer
26 | self.middlewares = middlewares
27 | }
28 |
29 | /// A subscript providing access to the state of the store.
30 | public subscript(dynamicMember keyPath: KeyPath & Sendable) -> T {
31 | state[keyPath: keyPath]
32 | }
33 |
34 | /// Use this method to mutate the state of the store by feeding actions.
35 | ///
36 | /// The reducer handles the action synchronously and updates `state` before any middleware runs,
37 | /// so middleware observes the post-reduction state when it processes the action.
38 | public func send(_ action: Action) async {
39 | state = reducer.reduce(oldState: state, with: action)
40 | await intercept(action)
41 | }
42 |
43 | private func intercept(_ action: Action) async {
44 | await withDiscardingTaskGroup { [state] group in
45 | for middleware in middlewares {
46 | group.addTask {
47 | if let nextAction = await middleware.process(state: state, with: action) {
48 | await self.send(nextAction)
49 | }
50 | }
51 | }
52 | }
53 | }
54 | }
55 |
56 | extension Store {
57 | /// Use this method to create another ``Store`` deriving from the current one.
58 | @available(*, deprecated, message: "Use multiple stores instead of derived store")
59 | public func derived(
60 | deriveState: @Sendable @escaping (State) -> DerivedState,
61 | deriveAction: @Sendable @escaping (DerivedAction) -> Action
62 | ) -> Store {
63 | let store = Store(
64 | initialState: deriveState(state),
65 | reducer: IdentityReducer(),
66 | middlewares: [
67 | ClosureMiddleware { _, action in
68 | await self.send(deriveAction(action))
69 | return nil
70 | }
71 | ]
72 | )
73 |
74 | enableStateObservation(for: store, deriveState: deriveState)
75 |
76 | return store
77 | }
78 |
79 | private func enableStateObservation(
80 | for store: Store,
81 | deriveState: @Sendable @escaping (State) -> DerivedState
82 | ) {
83 | withObservationTracking {
84 | let newState = deriveState(state)
85 | if store.state != newState {
86 | store.state = newState
87 | }
88 | } onChange: {
89 | Task {
90 | await self.enableStateObservation(for: store, deriveState: deriveState)
91 | }
92 | }
93 | }
94 | }
95 |
96 | import SwiftUI
97 |
98 | extension Store {
99 | /// Use this method to create a `SwiftUI.Binding` from any instance of `Store`.
100 | public func binding(
101 | extract: @escaping (State) -> Value,
102 | embed: @escaping (Value) -> Action
103 | ) -> Binding {
104 | .init(
105 | get: { extract(self.state) },
106 | set: { newValue, transaction in
107 | let action = embed(newValue)
108 |
109 | withTransaction(transaction) {
110 | self.state = self.reducer.reduce(oldState: self.state, with: action)
111 | }
112 |
113 | Task {
114 | await self.intercept(action)
115 | }
116 | }
117 | )
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/Sources/UnidirectionalFlow/Middleware.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Middleware.swift
3 | // UnidirectionalFlow
4 | //
5 | // Created by Majid Jabrayilov on 23.06.22.
6 | //
7 |
8 | /// A protocol that defines middleware for intercepting and processing actions in a unidirectional data flow architecture.
9 | ///
10 | /// Middleware provides a way to observe actions flowing through the store and optionally transform them
11 | /// or trigger side effects. It can be used for logging, analytics, API calls, async tasks, or any other side effects.
12 | public protocol Middleware: Sendable {
13 | associatedtype State: Sendable
14 | associatedtype Action: Sendable
15 |
16 | /// The method processing the current action and returning another one.
17 | func process(state: State, with action: Action) async -> Action?
18 | }
19 |
20 | struct OptionalMiddleware: Middleware {
21 | typealias State = Optional
22 |
23 | let middleware: any Middleware
24 |
25 | func process(state: State, with action: Action) async -> Action? {
26 | guard let state else {
27 | return nil
28 | }
29 | return await middleware.process(state: state, with: action)
30 | }
31 | }
32 |
33 | struct LiftedMiddleware: Middleware {
34 | let middleware: any Middleware
35 | let keyPath: KeyPath & Sendable
36 | let prism: Prism
37 |
38 | func process(state: LiftedState, with action: LiftedAction) async -> LiftedAction? {
39 | guard let action = prism.extract(action) else {
40 | return nil
41 | }
42 |
43 | guard let action = await middleware.process(state: state[keyPath: keyPath], with: action) else {
44 | return nil
45 | }
46 |
47 | return prism.embed(action)
48 | }
49 | }
50 |
51 | struct KeyedMiddleware: Middleware {
52 | let middleware: any Middleware
53 | let keyPath: KeyPath & Sendable
54 | let prism: Prism
55 |
56 | func process(state: KeyedState, with action: KeyedAction) async -> KeyedAction? {
57 | guard
58 | let (key, action) = prism.extract(action),
59 | let state = state[keyPath: keyPath][key]
60 | else {
61 | return nil
62 | }
63 |
64 | guard let nextAction = await middleware.process(state: state, with: action) else {
65 | return nil
66 | }
67 |
68 | return prism.embed((key, nextAction))
69 | }
70 | }
71 |
72 | struct OffsetMiddleware: Middleware {
73 | let middleware: any Middleware
74 | let keyPath: KeyPath & Sendable
75 | let prism: Prism
76 |
77 | func process(state: IndexedState, with action: IndexedAction) async -> IndexedAction? {
78 | guard
79 | let (index, action) = prism.extract(action),
80 | state[keyPath: keyPath].indices.contains(index)
81 | else {
82 | return nil
83 | }
84 |
85 | let state = state[keyPath: keyPath][index]
86 |
87 | guard let nextAction = await middleware.process(state: state, with: action) else {
88 | return nil
89 | }
90 |
91 | return prism.embed((index, nextAction))
92 | }
93 | }
94 |
95 | struct ClosureMiddleware: Middleware {
96 | let closure: @Sendable (State, Action) async -> Action?
97 |
98 | func process(state: State, with action: Action) async -> Action? {
99 | await closure(state, action)
100 | }
101 | }
102 |
103 | extension Middleware {
104 | /// Transforms the ``Middleware`` to operate over `Optional`.
105 | public func optional() -> some Middleware {
106 | OptionalMiddleware(middleware: self)
107 | }
108 |
109 | /// Transforms the ``Middleware`` to operate over `State` wrapped into another type.
110 | public func lifted(
111 | keyPath: KeyPath & Sendable,
112 | prism: Prism
113 | ) -> some Middleware {
114 | LiftedMiddleware(middleware: self, keyPath: keyPath, prism: prism)
115 | }
116 |
117 | /// Transforms the ``Middleware`` to operate over `State` in an `Array`.
118 | public func offset(
119 | keyPath: KeyPath & Sendable,
120 | prism: Prism
121 | ) -> some Middleware {
122 | OffsetMiddleware(middleware: self, keyPath: keyPath, prism: prism)
123 | }
124 |
125 | /// Transforms the ``Middleware`` to operate over `State` in a `Dictionary`.
126 | public func keyed(
127 | keyPath: KeyPath & Sendable,
128 | prism: Prism
129 | ) -> some Middleware {
130 | KeyedMiddleware(middleware: self, keyPath: keyPath, prism: prism)
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/Tests/UnidirectionalFlowTests/ReducerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReducerTests.swift
3 | // UnidirectionalFlowTests
4 | //
5 | // Created by Majid Jabrayilov on 23.06.22.
6 | //
7 | @testable import UnidirectionalFlow
8 | import Testing
9 |
10 | struct ReducerTests {
11 | struct State: Equatable {
12 | var counter: Int
13 | }
14 |
15 | enum Action: Equatable {
16 | case increment
17 | case decrement
18 | }
19 |
20 | struct CounterReducer: Reducer {
21 | func reduce(oldState: State, with action: Action) -> State {
22 | var state = oldState
23 |
24 | switch action {
25 | case .increment: state.counter += 1
26 | case .decrement: state.counter -= 1
27 | }
28 |
29 | return state
30 | }
31 | }
32 |
33 | @Test func optional() {
34 | var state: State? = State(counter: 10)
35 | let reducer = CounterReducer()
36 | let optionalReducer = reducer.optional()
37 | let newState = optionalReducer.reduce(oldState: state, with: .increment)
38 | #expect(newState == State(counter: 11))
39 |
40 | state = nil
41 | let anotherNewState = optionalReducer.reduce(oldState: state, with: .decrement)
42 | #expect(anotherNewState == nil)
43 | }
44 |
45 | @Test func offset() {
46 | struct OffsetState: Equatable {
47 | var value: [State] = [.init(counter: 1), .init(counter: 2)]
48 | }
49 |
50 | enum OffsetAction: Equatable {
51 | case action(Int, Action)
52 |
53 | static var prism: Prism {
54 | .init(embed: OffsetAction.action) {
55 | guard case let OffsetAction.action(offset, action) = $0 else {
56 | return nil
57 | }
58 |
59 | return (offset, action)
60 | }
61 | }
62 | }
63 |
64 | let offsetReducer = CounterReducer().offset(
65 | keyPath: \OffsetState.value,
66 | prism: OffsetAction.prism
67 | )
68 |
69 | var state = OffsetState()
70 |
71 | let newState = offsetReducer.reduce(oldState: state, with: .action(1, .increment))
72 | state.value[1].counter += 1
73 | #expect(newState == state)
74 |
75 | let anotherState = offsetReducer.reduce(oldState: state, with: .action(3, .increment))
76 | #expect(anotherState == state)
77 | }
78 |
79 | @Test func keyed() {
80 | struct KeyedState: Equatable {
81 | var value: [String: State] = ["one": .init(counter: 10)]
82 | }
83 |
84 | enum KeyedAction: Equatable {
85 | case action(String, Action)
86 |
87 | static var prism: Prism {
88 | .init(embed: KeyedAction.action) {
89 | guard case let KeyedAction.action(key, action) = $0 else {
90 | return nil
91 | }
92 | return (key, action)
93 | }
94 | }
95 | }
96 |
97 | let keyedReducer = CounterReducer().keyed(
98 | keyPath: \KeyedState.value,
99 | prism: KeyedAction.prism
100 | )
101 |
102 | var state = KeyedState()
103 | let newState = keyedReducer.reduce(oldState: state, with: .action("one", .increment))
104 | state.value["one"]?.counter += 1
105 | #expect(newState == state)
106 |
107 | let anotherNewState = keyedReducer.reduce(oldState: state, with: .action("two", .increment))
108 | #expect(anotherNewState == state)
109 | }
110 |
111 | @Test func combined() {
112 | let combinedReducer: some Reducer = CombinedReducer(
113 | reducers: [
114 | CounterReducer(), CounterReducer()
115 | ]
116 | )
117 |
118 | let state = State(counter: 0)
119 | let newState = combinedReducer.reduce(oldState: state, with: .increment)
120 |
121 | #expect(newState == .init(counter: 2))
122 | }
123 |
124 | @Test func lift() {
125 | struct LiftedState: Equatable {
126 | var state: State
127 | }
128 |
129 | enum LiftedAction: Equatable {
130 | case action(Action)
131 |
132 | static var prism: Prism {
133 | .init(embed: LiftedAction.action) {
134 | guard case let LiftedAction.action(action) = $0 else {
135 | return nil
136 | }
137 | return action
138 | }
139 | }
140 | }
141 |
142 | let liftedReducer = CounterReducer().lifted(
143 | keyPath: \LiftedState.state,
144 | prism: LiftedAction.prism
145 | )
146 |
147 | var state = LiftedState(state: .init(counter: 1))
148 | let newState = liftedReducer.reduce(oldState: state, with: .action(.increment))
149 | state.state.counter += 1
150 | #expect(newState == state)
151 | }
152 |
153 | @Test func identity() {
154 | let identityReducer: some Reducer = IdentityReducer()
155 | let state = State(counter: 1)
156 | let newState = identityReducer.reduce(oldState: state, with: .increment)
157 | #expect(state == newState)
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/Tests/UnidirectionalFlowTests/StoreTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StoreTests.swift
3 | // UnidirectionalFlowTests
4 | //
5 | // Created by Majid Jabrayilov on 23.06.22.
6 | //
7 | @testable import UnidirectionalFlow
8 | import Testing
9 |
10 | @MainActor struct StoreTests {
11 | struct State: Equatable {
12 | var counter = 0
13 | }
14 |
15 | enum Action: Equatable {
16 | case increment
17 | case decrement
18 | case sideEffect
19 | case set(Int)
20 | }
21 |
22 | struct TestMiddleware: Middleware {
23 | func process(state: State, with action: Action) async -> Action? {
24 | guard action == .sideEffect else {
25 | return nil
26 | }
27 |
28 | try? await Task.sleep(nanoseconds: 1_000_000_000)
29 | return Task.isCancelled ? nil : .increment
30 | }
31 | }
32 |
33 | struct TestReducer: Reducer {
34 | func reduce(oldState: State, with action: Action) -> State {
35 | var state = oldState
36 | switch action {
37 | case .increment:
38 | state.counter += 1
39 | case .decrement:
40 | state.counter -= 1
41 | case let .set(value):
42 | state.counter = value
43 | default:
44 | break
45 | }
46 | return state
47 | }
48 | }
49 |
50 | @Test func send() async {
51 | let store = Store(
52 | initialState: .init(),
53 | reducer: TestReducer(),
54 | middlewares: [TestMiddleware()]
55 | )
56 |
57 | #expect(store.counter == 0)
58 | await store.send(.increment)
59 | #expect(store.counter == 1)
60 | await store.send(.decrement)
61 | #expect(store.counter == 0)
62 | }
63 |
64 | @Test func middleware() async {
65 | let store = Store(
66 | initialState: .init(),
67 | reducer: TestReducer(),
68 | middlewares: [TestMiddleware()]
69 | )
70 |
71 | #expect(store.counter == 0)
72 | let task = Task { await store.send(.sideEffect) }
73 | #expect(store.counter == 0)
74 | await task.value
75 | #expect(store.counter == 1)
76 | }
77 |
78 | @Test func middlewareCancellation() async {
79 | let store = Store(
80 | initialState: .init(),
81 | reducer: TestReducer(),
82 | middlewares: [TestMiddleware()]
83 | )
84 |
85 | #expect(store.counter == 0)
86 | let task = Task { await store.send(.sideEffect) }
87 | try? await Task.sleep(nanoseconds: 10_000_000)
88 | #expect(store.counter == 0)
89 | task.cancel()
90 | await task.value
91 | #expect(store.counter == 0)
92 | }
93 |
94 | @Test func derivedStore() async throws {
95 | let store = Store(
96 | initialState: .init(),
97 | reducer: TestReducer(),
98 | middlewares: [TestMiddleware()]
99 | )
100 |
101 | let derived = store.derived(deriveState: { $0 }, deriveAction: { $0 } )
102 |
103 | #expect(store.counter == 0)
104 | #expect(derived.counter == 0)
105 |
106 | await store.send(.sideEffect)
107 |
108 | #expect(store.counter == 1)
109 | #expect(derived.counter == 1)
110 |
111 | await derived.send(.sideEffect)
112 |
113 | #expect(store.counter == 2)
114 | #expect(derived.counter == 2)
115 |
116 | let derivedTask = Task { await derived.send(.sideEffect) }
117 | derivedTask.cancel()
118 | await derivedTask.value
119 |
120 | try await Task.sleep(nanoseconds: 1_000_000_000)
121 |
122 | #expect(store.counter == 2)
123 | #expect(derived.counter == 2)
124 |
125 | let task = Task { await store.send(.sideEffect) }
126 | task.cancel()
127 | await task.value
128 |
129 | try await Task.sleep(nanoseconds: 1_000_000_000)
130 |
131 | #expect(store.counter == 2)
132 | #expect(derived.counter == 2)
133 |
134 | await store.send(.increment)
135 |
136 | #expect(store.counter == 3)
137 | #expect(derived.counter == 3)
138 |
139 | await derived.send(.decrement)
140 |
141 | #expect(store.counter == 2)
142 | #expect(derived.counter == 2)
143 | }
144 |
145 | @Test func binding() async {
146 | let store = Store(
147 | initialState: .init(),
148 | reducer: TestReducer(),
149 | middlewares: [TestMiddleware()]
150 | )
151 |
152 | let binding = store.binding(
153 | extract: \.counter,
154 | embed: Action.set
155 | )
156 |
157 | binding.wrappedValue = 10
158 | #expect(store.counter == 10)
159 | }
160 |
161 | @Test func threadSafety() async {
162 | let store = Store(
163 | initialState: .init(),
164 | reducer: TestReducer(),
165 | middlewares: [TestMiddleware()]
166 | )
167 |
168 | await withDiscardingTaskGroup { group in
169 | for _ in 1...100_000 {
170 | group.addTask {
171 | await store.send(.increment)
172 | }
173 | }
174 | }
175 |
176 | #expect(store.counter == 100_000)
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/Sources/UnidirectionalFlow/Reducer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Reducer.swift
3 | // UnidirectionalFlow
4 | //
5 | // Created by Majid Jabrayilov on 23.06.22.
6 | //
7 | import Foundation
8 |
9 | /// A protocol that defines how state mutations occur in response to actions.
10 | ///
11 | /// Reducers are pure functions that produce a new state by applying an action to the current state.
12 | /// They form the core of state mutation logic in a unidirectional data flow architecture.
13 | public protocol Reducer {
14 | associatedtype State
15 | associatedtype Action
16 |
17 | /// The function returning a new state by taking an old state and an action.
18 | func reduce(oldState: State, with action: Action) -> State
19 | }
20 |
21 | /// A type conforming to the ``Reducer`` protocol that doesn't apply any mutation to the old state.
22 | public struct IdentityReducer: Reducer {
23 | public func reduce(oldState: State, with action: Action) -> State {
24 | oldState
25 | }
26 | }
27 |
28 | /// The type of ``Reducer`` combining a `Collection` of reducers into one instance.
29 | public struct CombinedReducer: Reducer {
30 | let reducers: any Collection>
31 |
32 | public init(reducers: any Reducer...) {
33 | self.reducers = reducers
34 | }
35 |
36 | public init(reducers: some Collection>) {
37 | self.reducers = reducers
38 | }
39 |
40 | public func reduce(oldState: State, with action: Action) -> State {
41 | reducers.reduce(oldState) {
42 | $1.reduce(oldState: $0, with: action)
43 | }
44 | }
45 | }
46 |
47 | private struct LiftedReducer: Reducer {
48 | typealias State = LiftedState
49 | typealias Action = LiftedAction
50 |
51 | let reducer: any Reducer
52 | let keyPath: WritableKeyPath
53 | let prism: Prism
54 |
55 | func reduce(oldState: State, with action: Action) -> State {
56 | guard let loweredAction = prism.extract(action) else {
57 | return oldState
58 | }
59 |
60 | var oldState = oldState
61 |
62 | oldState[keyPath: keyPath] = reducer.reduce(
63 | oldState: oldState[keyPath: keyPath],
64 | with: loweredAction
65 | )
66 |
67 | return oldState
68 | }
69 | }
70 |
71 | private struct OptionalReducer: Reducer {
72 | typealias State = Optional
73 |
74 | let reducer: any Reducer
75 |
76 | func reduce(oldState: State, with action: Action) -> State {
77 | oldState.map { reducer.reduce(oldState: $0, with: action) } ?? oldState
78 | }
79 | }
80 |
81 | private struct KeyedReducer: Reducer {
82 | let reducer: any Reducer
83 |
84 | let keyPath: WritableKeyPath
85 | let prism: Prism
86 |
87 | func reduce(oldState: KeyedState, with action: KeyedAction) -> KeyedState {
88 | var oldState = oldState
89 |
90 | guard
91 | let (key, action) = prism.extract(action),
92 | let state = oldState[keyPath: keyPath][key]
93 | else {
94 | return oldState
95 | }
96 |
97 | let newState = reducer.reduce(oldState: state, with: action)
98 | oldState[keyPath: keyPath][key] = newState
99 |
100 | return oldState
101 | }
102 | }
103 |
104 | private struct OffsetReducer: Reducer {
105 | let reducer: any Reducer
106 |
107 | let keyPath: WritableKeyPath
108 | let prism: Prism
109 |
110 | func reduce(oldState: IndexedState, with action: IndexedAction) -> IndexedState {
111 | guard
112 | let (index, action) = prism.extract(action),
113 | oldState[keyPath: keyPath].indices.contains(index)
114 | else {
115 | return oldState
116 | }
117 |
118 | var oldState = oldState
119 | let newState = reducer.reduce(
120 | oldState: oldState[keyPath: keyPath][index],
121 | with: action
122 | )
123 | oldState[keyPath: keyPath][index] = newState
124 | return oldState
125 | }
126 | }
127 |
128 | extension Reducer {
129 | /// Transforms the reducer to operate over `State` wrapped into another type.
130 | public func lifted(
131 | keyPath: WritableKeyPath,
132 | prism: Prism
133 | ) -> some Reducer {
134 | LiftedReducer(reducer: self, keyPath: keyPath, prism: prism)
135 | }
136 |
137 | /// Transforms the reducer to operate over `State` in a `Dictionary`.
138 | public func keyed(
139 | keyPath: WritableKeyPath,
140 | prism: Prism
141 | ) -> some Reducer {
142 | KeyedReducer(reducer: self, keyPath: keyPath, prism: prism)
143 | }
144 |
145 | /// Transforms the reducer to operate over `State``State` in an `Array`.
146 | public func offset(
147 | keyPath: WritableKeyPath,
148 | prism: Prism
149 | ) -> some Reducer {
150 | OffsetReducer(reducer: self, keyPath: keyPath, prism: prism)
151 | }
152 |
153 | /// Transforms the reducer to operate over `Optional`.
154 | public func optional() -> some Reducer {
155 | OptionalReducer(reducer: self)
156 | }
157 | }
158 |
--------------------------------------------------------------------------------