Skip to content
yshrktPublic

About

A lightweight, unidirectional data flow for Swift inspired by Redux.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

NanoRedux

A lightweight, unidirectional data flow library for Swift, inspired by Redux. Built for SwiftUI with full Swift 6 concurrency support and Observation framework integration.

Requirements

  • Swift 6.2+
  • iOS 18+ / macOS 15+ / visionOS 2+ / watchOS 11+ / macCatalyst 18+

Installation

Swift Package Manager

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:

  1. File → Add Package Dependencies
  2. Enter the repository URL
  3. Select the version and add to your target

Core Concepts

Store

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())
)

Scope

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) }
)

Debug logging

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)"
}
  • .action logs each action immediately before the reducer processes it, including effect actions.
  • .state logs the current state immediately when configured, then after every processed action, even if unchanged. It does not log direct state mutations outside the reducer.
  • name labels 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 formatState closure receives the current root state and returns its log text. It runs only when .state logging 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 .state again logs the current state again. Omitting formatState restores automatic formatting.
  • Logging is available only on ReducerStore, before erasing it to any 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)

Reducer

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)
}

Effect

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 Failure type must be Never. For throwing streams, handle errors before passing to .stream (e.g., using compactMap to 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.

Usage Example

Basic Feature

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
        }
    }
}

Use in SwiftUI

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) }
            }
        }
    }
}

Composing Features

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.

License

NanoRedux is available under the MIT license. See the LICENSE file for more info.

About

A lightweight, unidirectional data flow for Swift inspired by Redux.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages