How to Build a Redux Pattern in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: StateObject / Reducer Updated: May 11, 2026
TL;DR

Create a generic Store class that owns your AppState and runs a pure Reducer function on every dispatched Action. Attach it with @StateObject at the root and pass it down via .environmentObject.

// State, Action, Reducer
struct AppState { var count = 0 }
enum AppAction  { case increment, decrement, reset }

func appReducer(state: inout AppState, action: AppAction) {
    switch action {
    case .increment: state.count += 1
    case .decrement: state.count -= 1
    case .reset:     state.count  = 0
    }
}

// Usage in a view
@StateObject private var store = Store(state: AppState(), reducer: appReducer)
// ...
Button("Tap") { store.dispatch(.increment) }
Text("\(store.state.count)")

Full implementation

The pattern has four layers: an immutable value-type State, an exhaustive Action enum, a side-effect-free Reducer that applies mutations, and a reference-type Store that is the single source of truth. The Store is generic so you can reuse it across every feature module. Injecting it through the SwiftUI environment means no view needs to accept the store as a constructor parameter.

import SwiftUI

// MARK: - State

struct AppState {
    var count:     Int    = 0
    var isLoading: Bool   = false
    var message:   String = "Ready"
}

// MARK: - Actions

enum AppAction {
    case increment
    case decrement
    case reset
    case setMessage(String)
}

// MARK: - Reducer (pure function, no side-effects)

func appReducer(state: inout AppState, action: AppAction) {
    switch action {
    case .increment:
        state.count  += 1
        state.message = "Incremented to \(state.count)"

    case .decrement:
        state.count  -= 1
        state.message = "Decremented to \(state.count)"

    case .reset:
        state.count   = 0
        state.message = "Reset"

    case .setMessage(let msg):
        state.message = msg
    }
}

// MARK: - Store

@MainActor
final class Store<State, Action>: ObservableObject {
    @Published private(set) var state: State
    private let reducer: (inout State, Action) -> Void

    init(
        state: State,
        reducer: @escaping (inout State, Action) -> Void
    ) {
        self.state   = state
        self.reducer = reducer
    }

    func dispatch(_ action: Action) {
        reducer(&state, action)
    }
}

// MARK: - Root view — owns the store

struct ReduxRootView: View {
    @StateObject private var store = Store(
        state:   AppState(),
        reducer: appReducer
    )

    var body: some View {
        CounterView()
            .environmentObject(store)
    }
}

// MARK: - Child view — reads store from environment

struct CounterView: View {
    @EnvironmentObject private var store: Store<AppState, AppAction>

    var body: some View {
        VStack(spacing: 28) {
            Text(store.state.message)
                .font(.caption)
                .foregroundStyle(.secondary)
                .accessibilityLabel("Status: \(store.state.message)")

            Text("\(store.state.count)")
                .font(.system(size: 80, weight: .bold, design: .rounded))
                .contentTransition(.numericText())
                .accessibilityLabel("Count: \(store.state.count)")

            HStack(spacing: 24) {
                Button {
                    store.dispatch(.decrement)
                } label: {
                    Image(systemName: "minus.circle.fill")
                        .font(.largeTitle)
                }
                .accessibilityLabel("Decrement count")

                Button {
                    store.dispatch(.increment)
                } label: {
                    Image(systemName: "plus.circle.fill")
                        .font(.largeTitle)
                }
                .accessibilityLabel("Increment count")
            }
            .tint(.blue)

            Button("Reset") { store.dispatch(.reset) }
                .buttonStyle(.bordered)
                .accessibilityLabel("Reset count to zero")
        }
        .padding(32)
        .animation(.spring, value: store.state.count)
    }
}

#Preview {
    ReduxRootView()
}

How it works

  1. AppState is a value type. Because AppState is a struct, passing it as inout to the reducer gives you an isolated copy — mutations never leak between dispatches.
  2. The reducer is a plain function. appReducer(state:action:) has no reference to Store, no async, no I/O. This makes it trivially unit-testable: call it with a state and action, assert the resulting state.
  3. Store is @MainActor. Marking the class @MainActor ensures dispatch(_:) always runs on the main thread, so SwiftUI's @Published update is always on the correct actor without extra DispatchQueue.main calls.
  4. @StateObject at the root. ReduxRootView owns the store with @StateObject, which guarantees it survives SwiftUI view re-evaluations. Only one instance exists for the lifetime of the app session.
  5. .environmentObject distributes the store. Any descendant view declared with @EnvironmentObject can read state or dispatch actions without constructor prop-drilling, mirroring how Redux's Provider / connect works in React.

Variants

Async middleware (Thunk-style side effects)

Real apps need network calls. Add a send(_:) overload that accepts an async closure — keeping async work outside the pure reducer.

// Extend Store with a Thunk sender
extension Store {
    typealias Thunk = (Store) async -> Void

    func send(_ thunk: @escaping Thunk) {
        Task { await thunk(self) }
    }
}

// Usage: async action that fetches then dispatches
store.send { store in
    store.dispatch(.setMessage("Loading…"))
    let count = try? await fetchCountFromServer()
    store.dispatch(.setMessage("Done: \(count ?? 0)"))
}

Scoped child stores

For large apps, scope a slice of AppState into a child store using a key-path lens: Store(parent: appStore, lens: \.featureState, reducer: featureReducer). This keeps feature modules self-contained while the root store remains the single source of truth. The child store forwards mutations up via the same inout pattern.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a Redux pattern in SwiftUI for iOS 17+.
Use StateObject for the root Store and a pure Reducer function.
Inject the store via .environmentObject.
Make it accessible (VoiceOver labels on all interactive controls).
Add a #Preview with realistic sample data showing at least two dispatched actions.

In Soarias, drop this prompt into the Build phase after your screen wireframes are approved — Claude Code will scaffold the Store, Reducer, and all connected views in one pass, letting you jump straight to customising your feature-specific state.

Related

FAQ

Does this work on iOS 16?

Yes — ObservableObject, @StateObject, and .environmentObject are all available from iOS 14+. The only iOS 17-specific addition in the example above is .contentTransition(.numericText()) — remove that modifier and the entire implementation compiles and runs on iOS 16.

How do I unit-test the reducer without SwiftUI?

Because the reducer is a free function with no dependencies, testing requires zero mocking:

func testIncrement() {
    var state = AppState()
    appReducer(state: &state, action: .increment)
    XCTAssertEqual(state.count, 1)
    XCTAssertEqual(state.message, "Incremented to 1")
}

No @MainActor, no XCTestExpectation, no view hosting needed.

What is the UIKit equivalent?

In UIKit the same pattern maps to a shared singleton (or injected) Store that view controllers observe via NotificationCenter or Combine's sink. Frameworks like ReSwift formalise exactly this for UIKit. In SwiftUI the @Published property on the Store replaces both, driving view updates automatically.

Last reviewed: 2026-05-11 by the Soarias team.