A lightweight Redux library for SwiftUI built on Swift structured concurrency. Unidirectional data flow, composable reducers, async middleware, and a macro that eliminates store boilerplate.
┌─────────────────────────────────────────────────────┐
│ │
│ View ──── dispatch ────▶ Action │
│ ▲ │ │
│ │ ┌──────────┴──────────┐ │
│ │ ▼ ▼ │
│ │ Reducer (sync) Middleware │
│ │ │ (async) │
│ └──── State ◀─────┘ │ │
│ next(Action) ─────┘
│
└─── unidirectional, @MainActor
Redux discipline usually comes bundled with ceremony — effect types to learn, dependency containers to wire, test harnesses to set up. ReduxCore keeps the unidirectional guarantees and drops the rest.
-
One macro per screen.
@StoreViewgenerates the store, theStoreandMiddlewaretypealiases, and thebody. You write actions, a reducer, and a view — the boilerplate is gone. -
Zero runtime dependencies. The only package is
swift-syntax, and it's a build-time macro plugin — nothing ships in your app binary. -
Side effects are plain
async/await. Middleware is just an async function. No customEffecttype, no scheduler. All middleware run in parallel, so one slow effect never blocks the others. -
Debouncing built in. Conform to
CancellableTaskand callrun(key:)— the previous task under that key is cancelled before the next starts. Search- as-you-type, delayed autosave, and any "only the last one matters" flow work out of the box, backed by an actor that keeps memory bounded. -
Cycle detection out of the box. Two diagnostics catch runaway dispatch loops — a frequency detector ("action fired 21× in under a second") and a depth detector for re-entrant chains. Both are DEBUG-only and never drop or delay a real dispatch.
-
Tests without mocks or machinery. Reducers are pure functions: build a state value, call
reduce, assert the result. Middleware is tested by passing a plainnextclosure and checking what it dispatched. You mock only the I/O boundary — everything inside the Redux layer is mock-free by design. -
Small enough to read in an afternoon. Three protocols, one macro, two composition operators (
Scopeandcombined(with:)). You can hold the whole framework in your head.
If you've felt that unidirectional data flow shouldn't require this much ceremony, that's the gap ReduxCore fills.
- iOS 17+ / macOS 14+
- Swift 5.9+
Add via File → Add Package Dependencies in Xcode, or in Package.swift:
.package(url: "https://github.com/felilo/ReduxCore", from: "1.1.0")Then import:
import ReduxCoreA complete real-world feature: async API calls, debounced search, navigation, a detail screen with its own store, and three composable middleware.
- Full walkthrough — step-by-step breakdown of every piece
- Source code — runnable Xcode project
The counter below shows the full pattern in one shot.
1 — Actions and state
enum CounterAction: Actionable {
case increment
case decrement
}
struct CounterState: Statable {
var count = 0
}2 — Reducer
struct CounterReducer: ReducerType {
func reduce(action: CounterAction, state: inout CounterState) {
switch action {
case .increment: state.count += 1
case .decrement: state.count -= 1
}
}
}3 — Screen
@StoreView(reducer: CounterReducer.self)
struct CounterScreen: View {
var middleware: [Middleware] { [] }
func content(store: Store) -> some View {
VStack {
Text("\(store.state.count)")
Button("+") { store.dispatch(.increment) }
Button("−") { store.dispatch(.decrement) }
}
}
}@StoreView generates body, the Store typealias, and the Middleware typealias. The store lives in a @State property inside StoreContainerView — it is created once and cancelled automatically when the view leaves the hierarchy.
Actions describe intent. They are pure values — no logic, no side effects.
enum SearchAction: Actionable {
case queryChanged(String)
case resultsLoaded([Item])
case failed(Error)
}Requires Equatable and Sendable. Swift synthesises both for enums automatically.
State is a value type initializable with no arguments.
struct SearchState: Statable {
var query = ""
var results: [Item] = []
var isLoading = false
}Requires Equatable, Sendable, and init(). Swift synthesises Equatable for structs automatically.
Reducers are pure and synchronous. No async code, no I/O, no side effects.
struct SearchReducer: ReducerType {
func reduce(action: SearchAction, state: inout SearchState) {
switch action {
case .queryChanged(let q):
state.query = q
state.isLoading = true
case .resultsLoaded(let items):
state.results = items
state.isLoading = false
case .failed:
state.isLoading = false
}
}
}state is inout — mutate it directly, return nothing.
Scope delegates a slice of parent state to a child reducer. The child type knows nothing about the parent — it only sees its own Action and State.
// Parent wraps child actions in a dedicated case
enum HomeAction: Actionable {
case header(HeaderAction)
case list(ListAction)
}
struct HomeState: Statable {
var header = HeaderState()
var list = ListState()
}
struct HomeReducer: ReducerType {
func reduce(action: HomeAction, state: inout HomeState) {
Scope(
state: \.header,
action: { guard case .header(let a) = $0 else { return nil }; return a }
) {
HeaderReducer()
}
.reduce(action: action, state: &state)
Scope(
state: \.list,
action: { guard case .list(let a) = $0 else { return nil }; return a }
) {
ListReducer()
}
.reduce(action: action, state: &state)
}
}Scope calls the action closure on every dispatch. If it returns nil the child reducer is skipped. Adding a new sub-feature is one new Scope block — nothing else changes.
Runs two reducers sequentially on the same action and state. Both must share the same types.
let pipeline = CoreReducer().combined(with: AnalyticsReducer())For full details on both patterns → Reducer Composition
Middleware handles async side effects. It receives every action after the reducer has already applied it to state.
struct SearchMiddleware: MiddlewareType, Sendable {
let api: APIClient
func process(
action: SearchAction,
state: SearchState,
dispatch: @escaping DispatchClosure<SearchAction>
) async {
guard case .queryChanged(let query) = action else { return }
let results = (try? await api.search(query)) ?? []
await dispatch(.resultsLoaded(results))
}
}All middleware run in parallel via withTaskGroup. A slow middleware never blocks others.
For debouncing, task cancellation, and cycle detection → Advanced Middleware
Apply to any View struct. Declare middleware and content — the macro generates everything else.
@StoreView(reducer: SearchReducer.self)
struct SearchScreen: View {
let api: APIClient
@MiddlewareResultBuilder
var middleware: [Middleware] {
SearchMiddleware(api: api)
}
func content(store: Store) -> some View {
List(store.state.results) { item in
Text(item.name)
}
.searchable(
text: Binding(
get: { store.state.query },
set: { store.dispatch(.queryChanged($0)) }
)
)
}
}typealias Store = ObservableStore<SearchReducer>
typealias Middleware = AnyMiddleware<SearchReducer.Action, SearchReducer.State>
typealias MiddlewareResultBuilder = MiddlewareBuilder<SearchReducer.Action, SearchReducer.State>
var body: some View {
StoreContainerView(
reducer: SearchReducer(),
middleware: middleware,
content: { store in content(store: store) }
)
}The store is held in @State. In-flight middleware effects are cancelled when the view is permanently removed (navigation pop, sheet dismiss). Transient disappearances — pushing a child screen, switching tabs — do not cancel it.
Reducers are pure functions — same inputs always produce the same output. Injecting a dependency into a reducer's initializer breaks that guarantee: the output now depends on hidden state invisible to the rest of the system.
If you need configuration, put it in state and dispatch a setup action on first appearance. If you need data from an external source, let middleware fetch it and dispatch the result.
For the full reasoning and patterns for each common case → Reducer Purity
store.scope(state:action:) creates a ScopedStore that a child view subscribes to. The child re-renders only when its derived state slice changes — not on every parent state update.
// In the parent content function
let headerStore = store.scope(
state: \.header,
action: { SearchAction.header($0) }
)
HeaderView(store: headerStore)struct HeaderView: View {
let store: ScopedStore<SearchReducer, HeaderState, HeaderAction>
var body: some View {
Text(store.state.title)
Button("Refresh") { store.dispatch(.refresh) }
}
}HeaderView knows nothing about SearchReducer or SearchState. Dispatched HeaderAction values are mapped to SearchAction transparently.
What ReduxCore manages:
Actionable / Statable conformances, reducers, middleware, Scope composition, and @StoreView screens.
What lives outside it:
- Navigation and routing (see SUICoordinator)
- UI layout — views are wired to the store but their structure is plain SwiftUI
- External API clients — injected into middleware, never owned by the framework
- Raw domain models (
TaskItem,User, etc.) — noimport ReduxCoreneeded
Hard rules:
- Reducers must be pure — no
async, no I/O. - Middleware owns all async work.
- Views only read
store.stateand write viastore.dispatch(_:). No business logic in views. - Domain models and service protocols carry zero framework or UI imports.
For testing patterns → Testing
For coordinator-based navigation that pairs cleanly with ReduxCore's decoupled views, see SUICoordinator — the Decoupled Views guide shows exactly how screens built with @StoreView plug in.