How to Build a Redux Pattern in SwiftUI
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
-
AppState is a value type. Because
AppStateis astruct, passing it asinoutto the reducer gives you an isolated copy — mutations never leak between dispatches. -
The reducer is a plain function.
appReducer(state:action:)has no reference toStore, noasync, no I/O. This makes it trivially unit-testable: call it with a state and action, assert the resulting state. -
Store is @MainActor. Marking the class
@MainActorensuresdispatch(_:)always runs on the main thread, so SwiftUI's@Publishedupdate is always on the correct actor without extraDispatchQueue.maincalls. -
@StateObject at the root.
ReduxRootViewowns the store with@StateObject, which guarantees it survives SwiftUI view re-evaluations. Only one instance exists for the lifetime of the app session. -
.environmentObject distributes the store. Any descendant view declared
with
@EnvironmentObjectcan read state or dispatch actions without constructor prop-drilling, mirroring how Redux'sProvider/connectworks 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
-
⚠️ iOS 17 @Observable vs ObservableObject.
The new
@Observablemacro (iOS 17+) cannot be used as an.environmentObject— that protocol is tied toObservableObject. If you migrate to@Observable, switch to.environment(store)instead. -
⚠️ Dispatching from background threads.
Because the
Storeis@MainActor, callingdispatchfrom aTaskrunning on a background executor will produce a Swift concurrency warning (or error in strict mode). Alwaysawait MainActor.run { store.dispatch(…) }after async work completes. -
⚠️ Fat state causing redundant redraws.
Every call to
dispatchreplaces the entire@Published statevalue, triggering all subscribers. Split unrelated concerns into separate stores — e.g.,NavigationStore,UserStore— so views only re-render when their slice changes.
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.