├── .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 | --------------------------------------------------------------------------------