ba01ei/swift-mini-redux
Minimal Redux implementation in Swift. Using Swift Observable. Inspired by TCA.
Basic example
import MiniRedux
import SwiftUI
@Observable class AStore: BaseStore<AStore.Action> {
// MARK: - State
var text = ""
var number = 1
// MARK: - Action
enum Action {
case action1
case action2
}
// MARK: - Reducer
override func reduce(_ action: Action) -> Effect<Action> {
switch action {
case .action1:
store.text = "..."
return .none
case .action2:
return .run { send in
let result = await someAsyncFunction(...)
await send(anotherAction)
}
}
}
}
struct AView: View {
let store: AStore
var body: some View {
Text(store.text) // observed automatically
Button("...") { store.send(.action1) }
}
}Rather than freeform logic in view models, we adhere to a pattern where:
- Every updatable UI element is driven by the store's state (e.g.,
store.text). - Every state change is triggered by an action sent to the store (e.g.,
store.send(.buttonTapped)). - Every asynchronous operation is managed through an
Effectreturned byreduce.
Benefits:
- Easy Debugging: Track state changes by printing the
actioninreduce. - Testable: Each action represents a user interaction. Call
sendand assert state updates. - Structured Concurrency: Async operations and data subscriptions follow Swift Concurrency.
- Automatic Cancellation: Cancellations of async tasks are handled automatically.
How to handle interactions between a parent view and a child view
A store can contain child stores. Each store has a delegate for action forwarding. The parent initializes a child store, providing a closure to map child actions back to parent actions.
See example in this unit test
How to handle a list view with each item having its own store
The parent can maintain a list of child stores. To prevent unnecessary re-initialization and allow children to handle internal state independently, use the updateInPlace helper. This ensures the child store object is reused as long as its ID remains constant.
See example in this unit test
Working with UIKit
Since the store is based on Swift's @Observable macro, it naturally works with UIKit. Starting with iOS 26, UIKit has built-in support for automatic observation tracking through the updateProperties() method, eliminating the need for manual observation setup.
See example in TableViewRepresentable.swift
Apps using MiniRedux
Cipher Challenge is a cipher decoding game built on this architecture. See the Swift source code.
The game can be downloaded here
Fitness Habit Tracker is another app built on this architecture. Download it here.
Comparison to TCA
TCA is a comprehensive framework, whereas MiniRedux is a minimalist implementation inspired by its philosophy.
State Management
TCA uses a struct for state, providing automatic Equatable conformance and easier debugging. MiniRedux uses properties on a class, leveraging the Swift @Observable macro directly (TCA uses its own @ObservableState). To facilitate debugging, MiniRedux stores provide a reflection property to compare state changes.
Reducer Structure
In TCA, the reducer is a separate struct, favoring "composition over inheritance." MiniRedux requires subclassing BaseStore and overriding the reduce function. This reduces boilerplate by maintaining logic and state in a single type.
Composition & Scoping
TCA uses "scoping" to derive child stores from a slice of parent state, allowing a single tree of state. In MiniRedux, children stores are directly owned by parents. While less elegant, it is simpler to implement and carries less performance overhead, and the entire state tree is still available through the reflection property.
Testing
TCA has mature testing infrastructure (like TestStore). MiniRedux is designed for testability but currently offers fewer helper utilities, which can be improved in the future.
Pre-Observation Implementation
An earlier implementation of this library exists for iOS versions below 17, which doesn't rely on the @Observable macro. Its state management is more similar to TCA's struct-based approach.
Basic example
The simplest counter app
import MiniRedux
import SwiftUI
struct Counter: Reducer {
struct State: Equatable {
var count = 0
}
enum Action {
case incrementTapped
case decrementTapped
}
@MainActor static func store() -> StoreOf<Self> {
return Store(initialState: State()) { state, action, send in
switch action {
case .incrementTapped:
state.count += 1
return .none
case .decrementTapped:
state.count -= 1
return .none
}
}
}
}
struct CounterView: View {
@ObservedObject private var store = Counter.store()
var body: some View {
VStack {
Text("\(store.state.count)")
Button("+") {
store.send(.incrementTapped)
}
Button("-") {
store.send(.decrementTapped)
}
}
}
}Async side effect
Return a Task in the reduce function to run asynchronously, calling send() to trigger subsequent actions.
struct RandomQuote: Reducer {
struct State: Equatable {
var text = ""
}
enum Action {
case getQuoteTapped
case quoteLoaded(String)
}
struct Response: Codable {
let quote: String
let author: String
}
@MainActor static func store() -> StoreOf<Self> {
return Store(initialState: State()) { state, action, send in
switch action {
case .getQuoteTapped:
state.text = "Loading..."
return run { send in
guard let url = URL(string: "https://cipher.lei.fyi/quote") else { return }
do {
let (data, _) = try await URLSession.shared.data(for: URLRequest(url: url))
let result = try JSONDecoder().decode(Response.self, from: data)
send(.quoteLoaded(result.quote + " - " + result.author))
} catch {
send(.quoteLoaded("Error: \(error)"))
}
}
.cancellable(id: "load", cancelInFlight: true)
case .quoteLoaded(let text):
state.text = text
return .none
}
}
}
}
struct RandomQuoteView: View {
@ObservedObject private var store = RandomQuote.store()
var body: some View {
VStack {
Text(store.state.text)
Button("Get Random Quote") {
store.send(.getQuoteTapped)
}
}
}
}You can also return an effect based on a Combine publisher.
// provide an initialAction during initialization to trigger work immediately
return Store(initialState: State(), initialAction: .initialized) { state, action, _ in
switch action {
case .initialized:
return .publisher {
map { result in
.resultPublished(result)
}
}
case .resultPublished(let result):
...
}
}
}Interactions between two stores
This example shows how a parent store can communicate with a child store in both directions.
If a parent store contains a child store, internal updates to the child won't automatically trigger a parent state update. This is because Equatable comparison for stores depends on their initial states. This separation helps prevent unnecessary view re-renders.
Lists of child stores
MiniRedux doesn't offer .scope(), but you can still maintain parent-child relationships within dynamic lists.
Check this comparison between TCA and MiniRedux. TCA's .scope() is slightly more concise, but the functional result is similar.
See the list unit test.
License
This library is released under the MIT license. See LICENSE for details.
Package Metadata
Repository: ba01ei/swift-mini-redux
Stars: 2
Forks: 0
Open issues: 0
Default branch: main
Primary language: swift
License: MIT
README: README.md