A lightweight, unidirectional data flow library for Swift, inspired by Redux. Built for SwiftUI with full Swift 6 concurrency support and Observation framework integration.
- Swift 6.2+
- iOS 18+ / macOS 15+ / visionOS 2+ / watchOS 11+ / macCatalyst 18+
Add NanoRedux to your Package.swift dependencies:
dependencies: [
.package(url: "https://github.com/yshrkt/NanoRedux.git", from: "0.2.0")
]Or add it via Xcode:
- File → Add Package Dependencies
- Enter the repository URL
- Select the version and add to your target
Store<State, Action> is an observable protocol shared by root and scoped stores. ReducerStore owns state and handles action dispatching; scoped stores project their parent's state and forward actions.
Actions use an unbounded buffer to avoid dropping bursts. Keep reducers fast and coalesce high-frequency events before dispatching to limit memory growth.
StoreBuilder.reducer(...) returns a concrete ReducerStore. Views can retain either a root or scoped store as @State private var store: any Store<MyState, MyAction>. Configure root-only features such as logging before assigning to this shared interface.
Create a store with a reducer:
let store = StoreBuilder.reducer(
initialState: MyFeature.State(),
reducer: MyFeature.Reducer()
)
// If your reducer conforms to InjectableReducer:
let injectedStore = StoreBuilder.reducer(
initialState: MyFeature.State(),
reducer: MyFeature.Reducer(dependencies: MyFeature.Dependencies())
)Scoping allows you to derive child stores that focus on specific parts of your state, perfect for composing features.
let childStore = parentStore.scope(
state: { $0.childState },
action: { ChildAction.child($0) }
)Enable console logging with an OptionSet. No logging is enabled by default.
let store = StoreBuilder.reducer(
initialState: CounterFeature.State(),
reducer: CounterFeature.Reducer()
).logging([.action, .state], name: "Counter")
store.logging() // All targets (the default argument is .all)
store.logging(.action) // Actions only
store.logging(.state) // State only
store.logging([]) // Disable logging
// Format only the state values relevant to this store:
store.logging(name: "Counter") { state in
"count: \(state.count)"
}.actionlogs each action immediately before the reducer processes it, including effect actions..statelogs the current state immediately when configured, then after every processed action, even if unchanged. It does not log direct state mutations outside the reducer.namelabels every log entry. When omitted it defaults to the root state type name, so specify distinct names for multiple stores with the same state type.- The optional
formatStateclosure receives the current root state and returns its log text. It runs only when.statelogging is enabled, at configuration time and after each action. It is never called in release builds. Actions keep their automatic formatting. - Each call replaces the previous options, name, and formatter and returns the same store. Configuring
.stateagain logs the current state again. OmittingformatStaterestores automatic formatting. - Logging is available only on
ReducerStore, before erasing it toany Store. Scoped stores do not expose logging configuration. Root logs include mapped actions from all scopes. - Logging is active only when NanoRedux is compiled with
DEBUG; release builds do not format or emit logs.
Values are formatted using Mirror, without requiring state types to adopt another protocol. Properties whose names begin with _$ (such as Observation's registrar) show only their name and type; their contents are not reflected. Other stored property names, including _count, are preserved. Nested values are expanded, with cycle detection and limits of 10 levels, 100 children per value, and 1,000 values per log entry. _$ is a display convention based on generated property names, not a public Observation guarantee.
Example output:
[Counter][State] CounterState
- _count: 0
- _$observationRegistrar: ObservationRegistrar (omitted)
[Counter][Action] increment
[Counter][State] CounterState
- _count: 1
- _$observationRegistrar: ObservationRegistrar (omitted)
A Reducer defines how your app's state changes in response to actions. Implement reduce(into:action:); callAsFunction(into:action:) is available as a default convenience.
public protocol Reducer {
associatedtype State: Observable
associatedtype Action: Sendable
func reduce(
into state: inout State,
action: Action
) -> Effect<Action>
}
public protocol InjectableReducer: Reducer {
associatedtype Dependencies
var dependencies: Dependencies { get }
init(dependencies: Dependencies)
}Effects handle side effects like network requests, timers, or any asynchronous operations. They can return actions to feed back into the store.
public struct Effect<Action: Sendable> {
public let lifetime: EffectLifetime
}Effect Lifetimes:
| Lifetime | Description |
|---|---|
.storeBound |
Automatically cancelled when the store is deinitialized (default) |
.replaceable(key:) |
Replaces any in-flight effect with the same key |
.detached |
Continues independently of the store lifecycle |
Example:
// Simple async effect
Effect.run {
let data = try await apiClient.fetchData()
return .dataLoaded(data)
}
// Replaceable effect (useful for debouncing/refreshing)
Effect.run(lifetime: .replaceable("fetch")) {
let result = try await apiClient.fetch()
return .fetchComplete(result)
}Effect Composition:
| Composition | Description |
|---|---|
.concat { ... } |
Runs child effects serially in declaration order |
.merge { ... } |
Runs child effects concurrently |
.throttle(for:key:) { ... } |
Runs the leading effect immediately and drops subsequent effects during the interval. The key defines which effects share the same throttle window |
.debounce(for:key:) { ... } |
Delays execution and keeps only the latest effect for the key. The key defines which pending effects replace one another |
Example:
// Serial execution
Effect.concat {
Effect.run { .step1Finished }
Effect.run { .step2Finished }
}
// or using instance method
Effect.run { .step1Finished }
.concat(.run { .step2Finished })
// Concurrent execution
Effect.merge {
Effect.run { .profileLoaded(try await api.profile()) }
Effect.run { .postsLoaded(try await api.posts()) }
}
// or using instance method
Effect.run { .profileLoaded(try await api.profile()) }
.merge(.run { .postsLoaded(try await api.posts()) })
// Leading-only throttle
Effect.throttle(for: .seconds(1), key: "save") {
Effect.run { .saveRequested }
}
// or using instance method
Effect.run { .saveRequested }
.throttled(for: .seconds(1), key: "save")
// Debounce latest input
Effect.debounce(for: .milliseconds(300), key: "search") {
Effect.run { .searchRequested }
}
// or using instance method
Effect.run { .searchRequested }
.debounced(for: .milliseconds(300), key: "search")Stream Effects:
Use .stream to handle AsyncStream and other AsyncSequence types. This is useful for observing continuous events like WebSocket messages, location updates, or notification center events.
// Basic stream
Effect.stream {
messageStream.map { .messageReceived($0) }
}
// With filtering and transformation
Effect.stream {
eventStream
.filter { $0.isValid }
.map { .validEventReceived($0) }
}
// With lifetime management
Effect.stream(lifetime: .replaceable("webSocket")) {
webSocketMessageStream.map { .messageReceived($0) }
}Note: The stream's
Failuretype must beNever. For throwing streams, handle errors before passing to.stream(e.g., usingcompactMapto filter or convert errors).
Testing Effects:
Use await effect.executeForTesting() in unit tests to obtain the last collected action. This helper bypasses debounce/throttle scheduling and lifetime policies; test those behaviors through a Store. See CHANGELOG.md for migration notes.
import NanoRedux
import Observation
enum CounterFeature {
@Observable
final class State {
var count = 0
}
enum Action: Sendable {
case increment
case decrement
case reset
}
struct Reducer: NanoRedux.Reducer {
func reduce(
into state: inout State,
action: Action
) -> Effect<Action> {
switch action {
case .increment:
state.count += 1
case .decrement:
state.count -= 1
case .reset:
state.count = 0
}
return .none
}
}
}struct CounterView: View {
@State var store: any Store<CounterFeature.State, CounterFeature.Action>
var body: some View {
VStack {
Text("\(store.state.count)")
HStack {
Button("-") { store.send(.decrement) }
Button("Reset") { store.send(.reset) }
Button("+") { store.send(.increment) }
}
}
}
}Combine multiple features into a single app-level store:
enum AppFeature {
@Observable
final class State {
var todo: TodoFeature.State
var settings: SettingsFeature.State
}
enum Action: Sendable {
case todo(TodoFeature.Action)
case settings(SettingsFeature.Action)
}
struct Dependencies: Sendable {
let todo: TodoFeature.Dependencies
}
struct Reducer: NanoRedux.InjectableReducer {
let dependencies: Dependencies
private let todoReducer: TodoFeature.Reducer
private let settingsReducer = SettingsFeature.Reducer()
init(dependencies: Dependencies) {
self.dependencies = dependencies
todoReducer = TodoFeature.Reducer(dependencies: dependencies.todo)
}
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case let .todo(childAction):
return todoReducer(
into: &state.todo,
action: childAction
).map { Action.todo($0) }
case let .settings(childAction):
return settingsReducer(
into: &state.settings,
action: childAction
).mapNone()
}
}
}
}For a complete working example with effects, see the Example-iOS project.
NanoRedux is available under the MIT license. See the LICENSE file for more info.