Last active
January 27, 2026 16:41
-
-
Save fxm90/c3f74f2c695377b17b1f80cf96a31114 to your computer and use it in GitHub Desktop.
Example showing how to use Redux with SwiftUI.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // Redux.swift | |
| // | |
| // Created by Felix Mau on 25.06.20. | |
| // Copyright © 2020 Felix Mau. All rights reserved. | |
| // | |
| import SwiftUI | |
| /// # Redux Architecture Overview | |
| /// | |
| /// This example implements a minimal Redux-style architecture in Swift using SwiftUI and structured | |
| /// concurrency. | |
| /// | |
| /// Redux is built around a few core concepts: | |
| /// | |
| /// ## Store | |
| /// | |
| /// The **Store** is the single source of truth for your application state. | |
| /// It owns the current state value and is responsible for: | |
| /// - Receiving actions | |
| /// - Producing new state via reducers | |
| /// - Broadcasting state changes to observers (e.g. SwiftUI views) | |
| /// | |
| /// ## Actions | |
| /// | |
| /// **Actions** are simple, declarative values that describe *what happened*. | |
| /// They contain no business logic—only intent. | |
| /// | |
| /// Actions are: | |
| /// - Dispatched by views or side effects | |
| /// - Consumed by reducers and middleware | |
| /// | |
| /// ## Reducers | |
| /// | |
| /// **Reducers** are pure functions, that based on the current action and the current state, create a new state. | |
| /// | |
| /// They: | |
| /// - Describe how actions transform state | |
| /// - Must be deterministic and side-effect free | |
| /// - Are easy to test synchronously | |
| /// | |
| /// ## Middleware | |
| /// | |
| /// **Middleware** intercepts actions to perform side effects such as: | |
| /// - Asynchronous work | |
| /// - API calls | |
| /// - Delayed actions | |
| /// | |
| /// Middleware can dispatch new actions back into the store. | |
| /// | |
| /// - SeeAlso: | |
| /// - <https://github.com/ReSwift/ReSwift> | |
| /// - <https://medium.com/swlh/how-to-handle-asynchronous-operations-with-redux-in-swift-495591d9df84> | |
| // MARK: - State | |
| /// The global application state. | |
| /// | |
| /// In Redux, the entire app / view state is represented as a single value. | |
| struct CounterState: Equatable, Sendable { | |
| var value: Int | |
| } | |
| // MARK: - Action | |
| /// All actions that can affect the application state. | |
| /// | |
| /// Actions are plain values describing *intent*, not implementation. | |
| /// | |
| /// - Synchronous actions are handled directly by the reducer. | |
| /// - Asynchronous actions are intercepted by middleware and typically result in one or more synchronous actions later. | |
| enum CounterAction: Equatable, Sendable { | |
| // Synchronous actions, handled by the reducer. | |
| case increase | |
| case decrease | |
| // Asynchronous actions, handled by the middleware. | |
| case asyncIncrease | |
| case asyncDecrease | |
| } | |
| // MARK: - Reducer | |
| /// The reducer for the application state. | |
| /// | |
| /// A reducer is a **pure function** that describes how an action transforms | |
| /// the current state into a new state. | |
| /// | |
| /// - Important: | |
| /// Reducers must **not** perform side effects such as: | |
| /// - Network calls | |
| /// - Delays | |
| /// - Dispatching new actions | |
| /// | |
| /// This makes reducers: | |
| /// - Predictable | |
| /// - Easy to reason about | |
| /// - Straightforward to unit test | |
| /// | |
| /// Typical test structure: | |
| /// - Given: an initial state | |
| /// - When: an action | |
| /// - Then: an expected new state | |
| func counterReducer(state: CounterState, action: CounterAction) -> CounterState { | |
| var mutableState = state | |
| switch action { | |
| case .increase: | |
| mutableState.value += 1 | |
| case .decrease: | |
| mutableState.value -= 1 | |
| case .asyncIncrease, .asyncDecrease: | |
| break | |
| } | |
| return mutableState | |
| } | |
| // MARK: - Middleware | |
| /// Dispatch function used by middleware to emit new actions. | |
| /// | |
| /// Middleware does not mutate state directly. | |
| /// Instead, it dispatches new actions back into the store. | |
| /// | |
| /// - Note: This mirrors `Store.dispatch(action:)`. | |
| typealias CounterDispatcher = (CounterAction) async -> Void | |
| /// Middleware function signature. | |
| /// | |
| /// Middleware is invoked *after* the reducer has processed an action. | |
| /// It can: | |
| /// - Inspect the current state | |
| /// - **Perform side effects** | |
| /// - Dispatch additional actions | |
| /// | |
| /// Middleware is ideal for: | |
| /// - Async operations | |
| /// - Delays | |
| /// - Logging | |
| /// - Analytics | |
| /// | |
| /// Typical test structure: | |
| /// - Given: Old State | |
| /// - When: Action | |
| /// - Then: Expect dispatched new Action | |
| typealias CounterMiddleware = (CounterState, CounterAction, @escaping CounterDispatcher) async -> Void | |
| /// Middleware that handles `.asyncIncrease`. | |
| /// | |
| /// - Parameter delay: Abstraction over `Task.sleep` to improve testability. | |
| func asyncIncreaseMiddleware( | |
| delay: @escaping (TimeInterval) async -> Void, | |
| ) -> CounterMiddleware { | |
| { _, action, dispatch in | |
| switch action { | |
| case .asyncIncrease: | |
| // Simulate an asynchronous operation. | |
| await delay(1.0) | |
| // Emit a synchronous action back into the store. | |
| await dispatch(.increase) | |
| default: | |
| break | |
| } | |
| } | |
| } | |
| /// Middleware that handles `.asyncDecrease`. | |
| /// | |
| /// - Parameter delay: Abstraction over `Task.sleep` to improve testability. | |
| func asyncDecreaseMiddleware( | |
| delay: @escaping (TimeInterval) async -> Void, | |
| ) -> CounterMiddleware { | |
| { _, action, dispatch in | |
| switch action { | |
| case .asyncDecrease: | |
| await delay(0.5) | |
| await dispatch(.decrease) | |
| default: | |
| break | |
| } | |
| } | |
| } | |
| // MARK: - Store | |
| /// The Redux store. | |
| /// | |
| /// Responsibilities: | |
| /// - Owns the current application state | |
| /// - Applies reducers to produce new state | |
| /// - Executes middleware for side effects | |
| /// | |
| /// The store is confined to the main actor so that: | |
| /// - State updates are serialized | |
| /// - SwiftUI views remain consistent | |
| @MainActor | |
| final class Store: ObservableObject { | |
| // MARK: - Public Properties | |
| @Published | |
| private(set) var state: CounterState | |
| // MARK: - Private Properties | |
| private let middlewares: [CounterMiddleware] | |
| // MARK: - Initializer | |
| init(state: CounterState, middlewares: [CounterMiddleware]) { | |
| self.state = state | |
| self.middlewares = middlewares | |
| } | |
| // MARK: - Public Methods | |
| /// Dispatches an action into the Redux pipeline. | |
| /// | |
| /// Action lifecycle: | |
| /// 1. The reducer synchronously produces a new state | |
| /// 2. The new state is published to observers | |
| /// 3. Middleware is executed for side effects | |
| /// | |
| /// - Important: | |
| /// This is the **only** way to mutate application state. | |
| func dispatch(action: CounterAction) async { | |
| print(Date().formatted(), "- Dispatching action:", action) | |
| // 1. Reduce | |
| state = counterReducer(state: state, action: action) | |
| // 2. Run middleware | |
| for middleware in middlewares { | |
| await middleware(state, action, dispatch) | |
| } | |
| } | |
| } | |
| // MARK: - View | |
| /// SwiftUI view that renders the counter state. | |
| /// | |
| /// The view: | |
| /// - Observes the store | |
| /// - Dispatches actions in response to user input | |
| /// - Contains no business logic | |
| struct CounterView: View { | |
| // MARK: - Private Properties | |
| @StateObject | |
| private var store: Store | |
| // MARK: - Initializer | |
| init(store: Store) { | |
| _store = StateObject(wrappedValue: store) | |
| } | |
| // MARK: - Render | |
| var body: some View { | |
| VStack(spacing: 16) { | |
| HStack(spacing: 4) { | |
| Text("Current value:") | |
| Text("\(store.state.value)") | |
| .fontWeight(.semibold) | |
| } | |
| Button("Increase value") { | |
| Task { await store.dispatch(action: .increase) } | |
| } | |
| .buttonStyle(.borderedProminent) | |
| Button("Decrease value") { | |
| Task { await store.dispatch(action: .decrease) } | |
| } | |
| .buttonStyle(.borderedProminent) | |
| Button("Increase after 1.0 seconds") { | |
| Task { await store.dispatch(action: .asyncIncrease) } | |
| } | |
| .buttonStyle(.borderedProminent) | |
| Button("Decrease after 0.5 seconds") { | |
| Task { await store.dispatch(action: .asyncDecrease) } | |
| } | |
| .buttonStyle(.borderedProminent) | |
| } | |
| .padding(16) | |
| } | |
| } | |
| // MARK: - Preview | |
| #Preview { | |
| let initialState = CounterState( | |
| value: 0, | |
| ) | |
| let middlewares: [CounterMiddleware] = [ | |
| asyncIncreaseMiddleware( | |
| delay: { try? await Task.sleep(for: .seconds($0)) }, | |
| ), | |
| asyncDecreaseMiddleware( | |
| delay: { try? await Task.sleep(for: .seconds($0)) }, | |
| ), | |
| ] | |
| let store = Store( | |
| state: initialState, | |
| middlewares: middlewares, | |
| ) | |
| CounterView(store: store) | |
| } |
Author
Author
Middleware Tests
import Foundation
import Testing
@Suite
struct AsyncIncreaseMiddleware {
private let mockDispatch = MockDispatch()
@Test
func action_asyncIncrease_shouldInvoke_delay() async {
// Given
let action: CounterAction = .asyncIncrease
let initialState = CounterState(value: 0)
var invokedDelayWithDuration: TimeInterval?
let middleware = asyncIncreaseMiddleware { duration in
invokedDelayWithDuration = duration
}
// When
await middleware(initialState, action, mockDispatch.dispatch(action:))
// Then
#expect(invokedDelayWithDuration == 1.0)
}
@Test
func action_asyncIncrease_shouldInvoke_dispatch_withIncrease() async {
// Given
let action: CounterAction = .asyncIncrease
let initialState = CounterState(value: 0)
let middleware = asyncIncreaseMiddleware { _ in }
// When
await middleware(initialState, action, mockDispatch.dispatch(action:))
// Then
#expect(mockDispatch.dispatchedActions == [.increase])
}
}
@Suite
struct AsyncDecreaseMiddleware {
private let mockDispatch = MockDispatch()
@Test
func action_asyncDecrease_shouldInvoke_delay() async {
// Given
let action: CounterAction = .asyncDecrease
let initialState = CounterState(value: 0)
var invokedDelayWithDuration: TimeInterval?
let middleware = asyncDecreaseMiddleware { duration in
invokedDelayWithDuration = duration
}
// When
await middleware(initialState, action, mockDispatch.dispatch(action:))
// Then
#expect(invokedDelayWithDuration == 0.5)
}
@Test
func action_asyncDecrease_shouldInvoke_dispatch_withDecrease() async {
// Given
let action: CounterAction = .asyncDecrease
let initialState = CounterState(value: 0)
let middleware = asyncDecreaseMiddleware { _ in }
// When
await middleware(initialState, action, mockDispatch.dispatch(action:))
// Then
#expect(mockDispatch.dispatchedActions == [.decrease])
}
}
// MARK: - Supporting Types
private final class MockDispatch {
private(set) var dispatchedActions: [CounterAction] = []
func dispatch(action: CounterAction) async {
dispatchedActions.append(action)
}
}
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Reducer Tests