Skip to content

Latest commit

 

History

102 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🚀 ReactableKit

🇰🇷 한국어 ReadMe

📌 Introduction

ReactableKit is a lightweight yet powerful state management framework for SwiftUI applications built on Combine.

Inspired by ReactorKit architecture, this framework provides a structured approach to efficiently handle business logic and state transformations.

📋 Requirements

  • ✅ iOS 16.0+
  • ✅ Swift 5+

📦 Installation

Swift Package Manager (SPM)

You can easily install ReactableKit using Swift Package Manager. Open your project in Xcode, select File > Add Packages... from the menu, and enter the following URL:

https://github.com/topwiz/ReactableKit.git

Or add it directly to your Package.swift file:

dependencies: [
    .package(url: "https://github.com/topwiz/ReactableKit.git", from: "version")
]

📚 Table of Contents

⚡ Basic Usage

🎨 Property Wrappers

🔬 Debugging

🔧 Features


⚡ Basic Usage

1️⃣ Core Structure of Reactable

To use Reactable, create a class that conforms to the Reactable protocol. Define Action, Mutation, and State, then implement mutate(action:) and reduce(state:mutation:).

final class CounterReactable: Reactable {
    enum Action: Sendable {
        case increase
        case decrease
        case loadData
    }
    
    struct State: Sendable {
        var count: Int = 0
        var isLoading: Bool = false
        var data: String = ""
        var errorMessage: String? = nil
    }
    
    enum Mutation: Sendable {
        case setCount(Int)
        case setLoading(Bool)
        case setData(String)
        case setError(String)
    }
    
    let initialState = State()
    
    func mutate(action: Action) -> AnyPublisher<Mutation, Never> {
        switch action {
        case .increase:
            return .just(.setCount(currentState.count + 1))
            
        case .decrease:
            return .run { send in 
                send(.setCount(self.currentState.count - 1))
            }
            
        case .loadData:
            return .run(priority: .userInitiated) { send in
                send(.setLoading(true))
                
                // Simulate async operation that can throw
                try await Task.sleep(nanoseconds: 1_000_000_000)
                let data = try await fetchDataFromAPI()
                
                send(.setData(data))
                send(.setLoading(false))
            } catch: { error, send in
                // Handle errors safely
                send(.setLoading(false))
                send(.setError(error.localizedDescription))
            }
        }
    }
    
    func reduce(state: inout State, mutation: Mutation) {
        switch mutate {
        case let .setCount(value):
            state.count = value
        case let .setLoading(isLoading):
            state.isLoading = isLoading
        case let .setData(data):
            state.data = data
        case let .setError(error):
            state.errorMessage = error
        }
    }
}

2️⃣ Action Transformation with transformAction

transformAction automatically enables event-based action triggers. This is useful for converting timers and various events into Reactable Actions.

func transformAction() -> AnyPublisher<Action, Never> {
    return Timer.publish(every: 5, on: .main, in: .common)
        .autoconnect()
        .map { _ in Action.autoIncrease }
        .eraseToAnyPublisher()
}

⚠️ Important: When not using Store and creating directly, make sure to call initialize() in the init method.

3️⃣ SwiftUI and Reactable

Use Store to detect state changes and dispatch Action in SwiftUI Views.

struct CounterView: View {
    @StateObject var store = Store { 
        CounterReactable()
    }
    
    var body: some View {
        VStack(spacing: 20) {
            Text("\(self.store.state.count)")
                .font(.largeTitle)
            
            Button("Increase") {
                self.store.action(.increase)
            }
        }
    }
}

4️⃣ updateOn: SwiftUI Update Optimization

You can reduce SwiftUI updates by monitoring only specific states:

struct OptimizedView: View {
    @ObservedObject var store = Store { CounterReactable() }

    var body: some View {
        VStack {
            // ✅ Updates only when `count` changes
            self.store.updateOn(\.count) { value in
                Text("\(value)")
                    .font(.headline)
            }
            
            // ✅ Using with action
            self.store.updateOn(\.isOn) { value in
                Toggle(isOn: value) {
                    Text("Toggle")
                }
            } action: { newValue in
                .toggleChanged
            }
            
            // ✅ ForEach example
            ForEach(self.store.state.items) { item in
                self.store.updateOn(\.items, for: item.id) { value in
                    Text("\(value.name)")
                }
            }
            
            // ✅ ForEach List multiple views example
            ForEach(self.store.state.list) { item in
                HStack {
                    self.store.updateOn(\.list, for: item.id, property: \.index) { value in
                        Text("\(value)")
                            .font(.headline)
                    }
                    
                    self.store.updateOn(\.list, for: item.id, property: \.toggle) { value in
                        Toggle(isOn: value) {
                            Text("Toggle 2 updateOn")
                        }
                    }
                }
            }
        }
    }
}

5️⃣ Action Dispatch

Normal Action

store.action(.increase)

Concurrency Action

You can receive the final state after the action is completely processed and the state is updated.

let finalState = await store.asyncAction(.increase)
print("Final count: \(finalState.count)")

🎨 Property Wrappers

@ViewState

@ViewState ensures automatic UI updates when values change. Properties without @ViewState do not trigger SwiftUI updates.

struct State {
    @ViewState var count: Int = 1
    /// When ignoreEquality = true, SwiftUI View updates even when the same value is set
    @ViewState(ignoreEquality: true) var forceUpdate: Bool = false
    /// animation: When animation is set, animation is applied when the value changes
    @ViewState(animation: .default) var animatedValue: Double = 0.0
}

@Shared

@Shared enables state sharing between parent and child components.

/// `file` and `UserDefaults` storage must conform to Codable
struct SharedState: Codable, Equatable {
    var username: String = ""
    var age: Int = 0
    var isPremium: Bool = false
}

struct State {
    @Shared(.file()) var sharedState = SharedState()
    @Shared(.file(path: "Test/")) var sharedState = SharedState() // Subfolder path
    @Shared var sharedState = SharedState()
    @Shared(key: "custom_key") var sharedState = SharedState() // Custom key
    @ViewState var displayInfo: String = ""
}

⚠️ @Shared does not automatically update the UI when values change.

@SharedViewState

@SharedViewState combines the sharing capabilities of @Shared with the automatic UI updating of @ViewState. It manages shared state values that trigger SwiftUI updates when changed.

struct State {
    @SharedViewState var sharedCount: Int = 0
    /// When ignoreEquality = true, SwiftUI View updates even when the same value is set
    @SharedViewState(ignoreEquality: true) var forceSharedUpdate: Bool = false
    /// animation: When animation is set, animation is applied when the value changes
    @SharedViewState(animation: .default) var animatedSharedValue: Double = 0.0
}

⚠️ Warning: Setting ignoreEquality to true may cause unnecessary updates to the SwiftUI view.

@Emit State Tracking

@Emit triggers updates even when the same value is set.

struct State {
    @Emit var title: String = "Hello"
}

Subscribing to emit(_:)

reactable.emit(\.$title)
    .sink { newValue in
        print("Title changed:", newValue)
    }
    .store(in: &cancellables)

Using @Emit in SwiftUI

ZStack { }
.emit(\.$title, from: self.store) { value in
    print("Title updated:", value)
}

🔧 Features

ObservableEvent (Parent-Child Communication)

ObservableEvent enables action transmission between child and parent components.

Basic: Static observe() and Instance observe()

// Child Reactable
class ChildReactable: Reactable, ObservableEvent {
    enum Action {
        case notifyParent(Int)
    }
}

// Parent Reactable - observe all instances (global)
func transformAction() -> AnyPublisher<Action, Never> {
    let childEvent = ChildReactable.observe()
        .filter { result in
            if case .notifyParent = result.action { return true }
            return false
        }
        .map(Action.parentAction)
        .eraseToAnyPublisher()

    // Observe specific instance (when child is always in state)
    let localChildEvent = self.currentState.childReactable.observe()
        .filter { result in
            if case .notifyParent = result.action { return true }
            return false
        }
        .map(Action.parentAction)
        .eraseToAnyPublisher()

    return .merge([childEvent, localChildEvent])
}

Recommended: child (Scoped to Your Child)

When the same child type is used by multiple parents, ChildType.observe() delivers events to all parents. Use child to receive only events from your child instance. Always call .observe() to subscribe:

// Parent State - child can be non-optional or optional
struct State {
    var childReactable: ChildReactable           // non-optional
    var optionalChild: ChildReactable?           // optional (e.g. lazy-loaded)
}

// Parent transformAction
func transformAction() -> AnyPublisher<Action, Never> {
    // Non-optional: call .observe() to subscribe
    let childEvents = self.child(\.childReactable)
        .observe()
        .filter { result in
            if case .notifyParent = result.action { return true }
            return false
        }
        .map(Action.parentAction)
        .eraseToAnyPublisher()

    // Optional: same pattern – call .observe() to subscribe
    let optionalChildEvents = self.child(\.optionalChild)
        .observe()
        .filter { result in
            if case .notifyParent = result.action { return true }
            return false
        }
        .map(Action.parentAction)
        .eraseToAnyPublisher()

    return .merge([childEvents, optionalChildEvents])
}

child benefits:

  • Unified API: Both optional and non-optional require .observe() – no confusion about when to call it
  • Filters by sourceId: Only your child's events are delivered
  • No wrong-parent routing: When multiple parents share the same child type, each parent receives only its own child's events

Chained child (Nested Optional Children)

When you have nested optional children (e.g. parent → optionalChild? → optionalGrandchild?), chain child calls and call .observe() at the end:

// 2-level chain (optional → optional)
self.child(\.fullRouteReactable)
    .child(\.routeDetailReactable)
    .observe()
    .sink { result in ... }
    .store(in: &cancellables)

// optional → non-optional grandchild
self.child(\.optionalChild)
    .child(\.grandchild)
    .observe()
    .sink { ... }
    .store(in: &cancellables)

// Single-level optional
self.child(\.optionalChild).observe().sink { ... }

ObservableEventResult

public struct ObservableEventResult<R: Reactable> {
    public let action: R.Action
    public var state: R.State
    /// Identifies the Reactable instance that sent this event (for `child` filtering)
    public let sourceId: ObjectIdentifier
}

ReactableView Protocol

Use the ReactableView protocol that follows @MainActor in UIKit views.

final class UIKitView: UIView {
    var cancellables: Set<AnyCancellable> = []
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.reactable = .init()
    }
}

extension UIKitView: ReactableView { 
    // Called when self.reactable is set
    func bind(reactable: UIKitReactable) { 

    }
}

DependencyInjectable & Factory Pattern

Combines dependency injection system with factory pattern to simplify object creation and dependency management in real, preview, and test environments.

1. DependencyInjectable

protocol ServiceProtocol {
    func test() -> String
}

struct Service: ServiceProtocol {
     func test() -> String { "real" }
    
    struct Mock: ServiceProtocol {
        public init() {}
        public func test() -> String { "mock" }
    }
    
    struct TestMock: ServiceProtocol {
        public init() {}
        public func test() -> String { "test" }
    }
}

// Use `MainActorDependencyInjectable` if you need to follow MainActor

extension Service: DependencyInjectable {
    static var real: ServiceProtocol { Service() }
    static var preview: ServiceProtocol { Service.Mock() }
    static var test: ServiceProtocol { Service.TestMock() }
}

extension GlobalDependencyKey {
    var service: ServiceProtocol {
        self[Service.self]
    }
}

// usage
@Dependency(\.service) var service

2. Factory

Use ViewFactory for factories that require @MainActor.

final class TestObject: Factory {
    struct Payload {
        var text: String
    }

    let payload: Payload
    
    init(payload: Payload) {
        self.payload = payload
    }
    
    func print1() {
        print(self.payload.text)
    }
}

extension TestObject: DependencyInjectable {
    typealias DependencyType = TestObject.Factory
    static var real: TestObject.Factory { .init() }
}

extension GlobalDependencyKey {
    var testObjectFactory: TestObject.Factory {
        self[TestObject.self]
    }
}

// usage
@Dependency(\.testObjectFactory) var testObjectFactory

3. AnyFactory

AnyFactory is a generic wrapper that abstracts the object creation process. It creates objects using Factory and transforms them into the desired output type through transformation closures.

extension MyFactory: DependencyInjectable {
    typealias DependencyType = AnyFactory<`ProtocolType`, Payload>
    
    static var real: DependencyType {
        AnyFactory(factory: MyFactory.Factory())
    }
    
    static var test: DependencyType {
        AnyFactory(factory: MockFactory.Factory())
    }
}

🔬 Debugging — ReactableInstrument

ReactableInstrument measures every stage of the Reactable cycle and warns when one of them takes too long in DEBUG builds. It can also emit selected stage lifecycle events in all build configurations through a target callback.

action(_:) ─▶ [Queue] ─▶ [Mutate] ─▶ [Effect] ─▶ [Reduce] ─▶ state
Stage What it measures
Queue How long the action waited on the main queue before mutate ran
Mutate Synchronous cost of building the mutation publisher
Effect Lifetime of that publisher, from subscription to completion
Reduce Synchronous cost of applying a mutation to the state

Queue is the one to watch for a stalled UI: every individual stage can look healthy while actions pile up behind a blocked main queue, and only queue latency shows that.

Turning it on

Instrumentation is opt-in. Override instrumentation on the Reactable you care about:

extension MyHighFrequencyReactable {
    var instrumentation: ReactableInstrument.Options<Action>? { .default }
}

Options takes a label to tell several instances of the same type apart, and a warningThreshold to override the global one for this Reactable:

var instrumentation: ReactableInstrument.Options<Action>? {
    .init(label: "carplay", warningThreshold: 0.016)   // one frame at 60fps
}

To measure everything without touching any code, set REACTABLE_INSTRUMENT to 1 in the scheme's environment variables, or flip it at runtime from a debug menu:

ReactableInstrument.enabledByDefault = true
ReactableInstrument.filter = { $0.contains("Guide") }   // by Reactable type name

Precedence: a Reactable's own instrumentation always wins. Otherwise, when a filter is set it decides on its own — a non-matching type name stays un-instrumented even with enabledByDefault or REACTABLE_INSTRUMENT on, so a filter narrows a global switch rather than widening it.

Warnings

Anything at or above the threshold (50 ms by default) is logged:

Slow reduce: playground.MyReactable updateLocation took 200.3ms (threshold 50.0ms)

Warnings are rate limited to one per reactable/stage/case per warningInterval (1 s by default), so a high-frequency stream cannot flood the log. Statistics still record every sample.

Effect is measured upstream of reduce, and Combine delivers a value synchronously through reduce before the completion event arrives — so a slow reduce inflates the Effect sample as well. When Slow effect and Slow reduce name the same action, the reduce is the cause; the effect is not doing async work.

Aggregated report

print(ReactableInstrument.report())
ReactableInstrument report (threshold 50.0ms)
REACTABLE    STAGE   CASE                COUNT        P50        P90        MAX
MyReactable  queue   updateLocation        412      0.8ms     12.0ms    137.4ms
MyReactable  reduce  setLocation           412      0.1ms      0.2ms      4.1ms

Observing every measurement

ReactableInstrument.onEvent = { event in
    // event.stage, event.reactable, event.name, event.duration
}

Do not dispatch an action into an instrumented Reactable from this hook — the resulting cycle would emit more events, forever.

Instruments

Edit Scheme ▸ Profile ▸ Build Configuration = Debug, then ⌘I, add the os_signpost instrument, and look for subsystem ReactableInstrument.subsystem (the app's bundle identifier by default) / category Reactable.

Try it

The sample app has a playground screen — 📈 ReactableInstrument Playground in Example/SampleProject — with sliders for the threshold and the per-action cost, buttons that make each stage slow on purpose, and a burst/flood stress test that reproduces a main-queue backlog.

📊 Target lifecycle events

ReactableInstrument.Target observes selected stages for matching actions. Targets are generic and can be consumed by any release-safe metrics system through onTargetEvent.

var instrumentation: ReactableInstrument.Options<Action>? {
    .init(targets: [
        .init(
            .init(rawValue: "guide-helper.update-location"),
            observing: [.effect],
            emittingIn: .all
        ) { (action: Action) in
            guard case .locationUpdated = action else { return false }
            return true
        }
    ])
}

ReactableInstrument.onTargetEvent = { event in
    // Dispatch blocking work to a consumer-owned queue.
    print(event)
}

emittingIn accepts .debugOnly, .releaseOnly, or .all. mutate measures the synchronous mutate(action:) call, reduce measures each synchronous reduce(state:mutation:) call, and effect measures the returned publisher from subscription to completion. effect includes synchronous reductions delivered before publisher completion and is suitable for one cycle-level metric. Selected stages for one action share a cycle ID; a publisher cancellation produces cancelled instead of finished.

The default target list and target callback are both empty. When no target callback is installed, the target path returns before target allocation or timestamping. Target callbacks run on the stream's delivery thread and must return promptly.

⏱️ Instrumentation cost

Instrumentation is not free, and DEBUG costs far more than release. Measured by driving 1,000,000 actions through a Reactable whose mutate and reduce do almost nothing, on an iOS simulator — the worst case, where nothing hides the overhead behind real work:

DEBUG Release
no instrumentation 5.9 µs/action 4.5 µs/action
instrumentation set, no targets 18.4 µs (×3.11) 4.5 µs (×1.00)
targets declared, no handler installed 18.8 µs (×3.18) 4.5 µs (×0.99)
targets on all three stages, handler installed 24.0 µs (×4.06) 7.6 µs (×1.68)

Two things make DEBUG worse. It runs the signpost, statistics and warning code that a release build compiles out entirely, and that code is built -Onone: the same target path costs +3.1 µs in release and +5.6 µs in DEBUG.

What follows from the numbers:

  • Leaving targets declared is safe. With no targets, or with targets but no onTargetEvent installed, the cost is not measurable — the path returns before any allocation or timestamp.
  • DEBUG instrumentation distorts what it measures. Roughly 3× on a trivial cycle. Turning it on for a high-frequency Reactable changes the very timings you are reading, so opt in per Reactable rather than reaching for enabledByDefault.
  • ReactableInstrument.statisticsEnabled = false removes about 12% of the DEBUG overhead when the aggregated report is not needed.
  • A mutate/reduce that does real work shrinks every ratio above, because the fixed per-stage cost is then a smaller share of the cycle.

🏗️ Roadmap

  • 💻 Mac Support
  • 🚀 Performance Optimizations

🔗 References

📜 License

ReactableKit is available under the MIT license.

Releases

Used by

Contributors

Languages