Skip to content

Instantly share code, notes, and snippets.

@fxm90
Last active January 27, 2026 16:41
Show Gist options
  • Select an option

  • Save fxm90/c3f74f2c695377b17b1f80cf96a31114 to your computer and use it in GitHub Desktop.

Select an option

Save fxm90/c3f74f2c695377b17b1f80cf96a31114 to your computer and use it in GitHub Desktop.
Example showing how to use Redux with SwiftUI.
//
// 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)
}
@fxm90
Copy link
Author

fxm90 commented Jan 27, 2026

Reducer Tests

import Testing

@Suite
struct ReducerTests {

  @Test
  func action_increase_shouldIncreaseValue() {
    // Given
    let action: CounterAction = .increase
    let initialState = CounterState(value: 0)

    // When
    let state = counterReducer(state: initialState, action: action)

    // Then
    #expect(state.value == 1)
  }

  @Test
  func action_decrease_shouldDecreaseValue() {
    // Given
    let action: CounterAction = .decrease
    let initialState = CounterState(value: 1)

    // When
    let state = counterReducer(state: initialState, action: action)

    // Then
    #expect(state.value == 0)
  }

  @Test
  func action_asyncIncrease_shouldNotUpdateState() {
    // Given
    let action: CounterAction = .asyncIncrease
    let initialState = CounterState(value: 0)

    // When
    let state = counterReducer(state: initialState, action: action)

    // Then
    #expect(state == initialState)
  }

  @Test
  func action_asyncDecrease_shouldNotUpdateState() {
    // Given
    let action: CounterAction = .asyncDecrease
    let initialState = CounterState(value: 0)

    // When
    let state = counterReducer(state: initialState, action: action)

    // Then
    #expect(state == initialState)
  }
}

@fxm90
Copy link
Author

fxm90 commented Jan 27, 2026

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