SwiftUIRedux is a modern state management library designed specifically for SwiftUI, seamlessly combining Redux core patterns with Swift's type safety. Inspired by [Redux] and swift-composable-architecture, it provides a more lightweight and efficient solution than similar frameworks, covering 90% of SwiftUI state management scenarios.
- π Strict Unidirectional Data Flow: Enforces Action β Reducer β State closed-loop management
- π‘οΈ Type Safety: Full type inference from Action definitions to State mutations
- π Two-way Binding: Native SwiftUI two-way binding support for
store.property
- π Hybrid State:
- Published State - Core state driving view updates
- Internal State - Non-reactive state for temporary storage (e.g., storing scrollView offset values without affecting performance)
- β³ ThunkMiddleware: Handles async tasks and side effects
- π‘ ActionPublisherMiddleware: Global Action monitoring pipeline
- π LoggingMiddleware: Development debugging with action tracing
- πͺ HookMiddleware: Custom lifecycle hooks
// Package.swift
dependencies: [
.package(url: "https://github.com/happyo/SwiftUIRedux.git", from: "1.1.2")
]
import SwiftUI
import SwiftUIRedux
struct BasicCounterView: View {
@StateObject private var store: Store<BasicCounterFeature> = StoreFactory.createStore()
var body: some View {
VStack(spacing: 20) {
Text("Current Count: \(store.state.count)")
.font(.largeTitle)
Text("Input string: \(store.state.inputString)")
HStack(spacing: 20) {
Button("β") { store.send(.decrement) }
.buttonStyle(CircleButtonStyle(color: .red))
Button("+") { store.send(.increment) }
.buttonStyle(CircleButtonStyle(color: .green))
}
TextField("Please input something", text: store.inputString)
.padding()
}
.navigationTitle("Basic Counter")
}
}
struct BasicCounterFeature: Feature {
struct State: Equatable {
var count = 0
var inputString: String = ""
}
enum Action: Equatable {
case increment
case decrement
}
struct Reducer: ReducerProtocol {
func reduce(oldState: State, action: Action) -> State {
var state = oldState
switch action {
case .increment:
state.count += 1
case .decrement:
state.count -= 1
}
return state
}
}
static func initialState() -> State { State() }
static func createReducer() -> Reducer { Reducer() }
}
Use store.inputString
to directly obtain Binding type, equivalent to @State
's $inputString
. While this approach may slightly deviate from pure Redux philosophy, it significantly simplifies real-world usage.
struct BasicCounterView: View {
@StateObject private var store: Store<BasicCounterFeature> = StoreFactory.createStore()
var body: some View {
// ...
Text("Input string: \(store.state.inputString)")
// ...
TextField("Please input something", text: store.inputString)
.padding()
}
}
Synchronous state updates automatically occur on the main thread. For async operations, use ThunkMiddleware and ThunkEffectAction, or use AsyncEffectAction to await:
struct EffectCounterFeature: Feature {
// ... (State and Action definitions)
static func createFetchAsyncRandomNumberAction() -> ThunkEffectAction<State, Action> {
ThunkEffectAction<State, Action> { dispatch, getState in
let state = getState()
print("Current random number: \(state.randomNumber)")
Task {
dispatch(.startLoading)
try? await Task.sleep(nanoseconds: 2 * 1_000_000_000)
let randomNumber = Int.random(in: 1...100)
dispatch(.setNumber(randomNumber))
dispatch(.endLoading)
}
}
}
static func createFetchAsyncRandomNumberActionWithAsyncEffect() -> AsyncEffectAction<State, Action> {
AsyncEffectAction<State, Action> { dispatch, getState in
let state = getState()
print("Current random number (Async): \(state.randomNumber)")
try? await Task.sleep(nanoseconds: 2 * 1_000_000_000)
let randomNumber = Int.random(in: 1...100)
dispatch(.setNumber(randomNumber))
}
}
static func createFetchAsyncRandomNumberActionWithAsyncEffectAnimation() -> AsyncAnimationEffectAction<State, Action> {
AsyncAnimationEffectAction<State, Action> { dispatch, getState in
let state = getState()
print("Current random number (Async): \(state.randomNumber)")
dispatch(.startLoading, .default)
try? await Task.sleep(nanoseconds: 2 * 1_000_000_000)
let randomNumber = Int.random(in: 1...100)
dispatch(.setNumber(randomNumber), .default)
dispatch(.endLoading, .default)
}
}
}
Store temporary values that don't trigger view updates using InternalState:
struct MixedStateFeature: Feature {
struct State {
var publishedCount = 0
}
struct InternalState {
var notPublishedCount = 0
}
static func middlewares() -> [AnyMiddleware<MixedStateFeature>] {
return [AnyMiddleware(ThunkMiddleware())]
}
static func createAddCountLessThanMaxAction()-> ThunkEffectWithInternalStateAction<State, Action, InternalState> {
ThunkEffectWithInternalStateAction<State, Action, InternalState> { dispatch, getState, getInternalState, setInternalState in
let state = getState()
let internalState = getInternalState()
if let maxCount = internalState?.maxCount {
if state.publishedCount < maxCount {
withAnimation {
dispatch(.incrementPublished)
}
} else {
print("Cannot increment, published count is already at max count.")
}
}
}
}
// ... (Other feature components)
}
Extend functionality with middleware components:
struct MiddlewareFeature: Feature {
// ... (State and Action definitions)
static func middlewares() -> [AnyMiddleware<MiddlewareFeature>] {
let loggingMiddleware = LoggingMiddleware<MiddlewareFeature>()
return [AnyMiddleware(loggingMiddleware)]
}
}
- Immutable State - Always return new state through reducers
- Minimal State - Only store essential data
- Store Ownership - Mark View-owned stores with
@StateObject
to prevent recreation issues
State Type | Usage Scenario | Update Mechanism |
---|---|---|
Published State | Data requiring view updates | Modified via Actions |
Internal State | Temporary storage/intermediate | Direct modification |